Skip to content

Commit 7551592

Browse files
authored
Merge pull request #466 from giorgikh93/lockedV2
[v2] [desktop] Locked screen
2 parents 210d0c3 + 9ac223e commit 7551592

5 files changed

Lines changed: 478 additions & 3 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit'
2+
import { rawTokens } from '@tetherto/pearpass-lib-ui-kit'
3+
4+
export const createStyles = (colors: ThemeColors) => ({
5+
root: {
6+
display: 'flex',
7+
flexDirection: 'column' as const,
8+
alignItems: 'stretch',
9+
width: '100%',
10+
minHeight: '100%',
11+
boxSizing: 'border-box' as const
12+
},
13+
main: {
14+
flex: 1,
15+
display: 'flex',
16+
flexDirection: 'column' as const,
17+
alignItems: 'center',
18+
justifyContent: 'center',
19+
width: '100%',
20+
minHeight: 0,
21+
maxWidth: '520px',
22+
marginLeft: 'auto',
23+
marginRight: 'auto',
24+
padding: `${rawTokens.spacing16}px ${rawTokens.spacing24}px`,
25+
textAlign: 'center' as const,
26+
boxSizing: 'border-box' as const
27+
},
28+
29+
pageHeaderWrap: {
30+
marginBottom: `${rawTokens.spacing6}px`,
31+
width: '100%'
32+
},
33+
34+
description: {
35+
display: 'flex',
36+
flexDirection: 'column' as const,
37+
gap: `${rawTokens.spacing4}px`,
38+
maxWidth: '420px'
39+
},
40+
41+
descriptionText: {
42+
fontSize: `${rawTokens.fontSize14}px`,
43+
fontWeight: rawTokens.weightRegular,
44+
color: colors.colorTextSecondary,
45+
margin: 0
46+
},
47+
48+
pill: {
49+
display: 'flex',
50+
flexDirection: 'row' as const,
51+
alignItems: 'center',
52+
justifyContent: 'space-between',
53+
width: '100%',
54+
maxWidth: '440px',
55+
marginTop: `${rawTokens.spacing24}px`,
56+
padding: `${rawTokens.spacing10}px ${rawTokens.spacing8}px`,
57+
borderRadius: `${rawTokens.spacing10}px`,
58+
border: `1px solid ${colors.colorBorderPrimary}`,
59+
boxSizing: 'border-box' as const
60+
},
61+
62+
pillText: {
63+
fontSize: `${rawTokens.fontSize14}px`,
64+
fontWeight: rawTokens.weightMedium,
65+
color: colors.colorTextSecondary
66+
},
67+
68+
pillLeft: {
69+
display: 'flex',
70+
flexDirection: 'row' as const,
71+
alignItems: 'center',
72+
gap: `${rawTokens.spacing8}px`,
73+
minWidth: 0
74+
},
75+
76+
countdown: {
77+
color: colors.colorPrimary,
78+
fontVariantNumeric: 'tabular-nums' as const,
79+
fontSize: `${rawTokens.fontSize14}px`,
80+
fontWeight: rawTokens.weightMedium,
81+
flexShrink: 0 as const
82+
}
83+
})
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/// <reference types="@testing-library/jest-dom" />
2+
3+
import React from 'react'
4+
5+
import '@testing-library/jest-dom'
6+
import { act, render, screen, waitFor } from '@testing-library/react'
7+
8+
import { NAVIGATION_ROUTES } from '../../../constants/navigation'
9+
import { LockedScreenV2 } from './LockedScreenV2'
10+
11+
;(globalThis as { React?: typeof React }).React = React
12+
13+
type MasterPasswordLockStatus = {
14+
isLocked?: boolean
15+
lockoutRemainingMs?: number
16+
}
17+
18+
const mockNavigate = jest.fn()
19+
jest.mock('../../../context/RouterContext', () => ({
20+
useRouter: () => ({ navigate: mockNavigate })
21+
}))
22+
23+
jest.mock('../../../hooks/useTranslation', () => ({
24+
useTranslation: () => ({
25+
t: (str: string) => str
26+
})
27+
}))
28+
29+
jest.mock('../../../components/OnboardingShell', () => ({
30+
OnboardingShell: ({ children }: { children: React.ReactNode }) => (
31+
<div data-testid="onboarding-shell-mock">{children}</div>
32+
)
33+
}))
34+
35+
const mockRefreshMasterPasswordStatus = jest.fn<
36+
() => Promise<MasterPasswordLockStatus | undefined>
37+
>()
38+
39+
jest.mock('@tetherto/pearpass-lib-vault', () => ({
40+
useUserData: () => ({
41+
refreshMasterPasswordStatus: mockRefreshMasterPasswordStatus
42+
})
43+
}))
44+
45+
jest.mock('@tetherto/pearpass-lib-ui-kit', () => {
46+
const actual = jest.requireActual<
47+
typeof import('@tetherto/pearpass-lib-ui-kit')
48+
>('@tetherto/pearpass-lib-ui-kit')
49+
50+
return {
51+
...actual,
52+
useTheme: () => ({
53+
theme: {
54+
colors: {
55+
colorTextSecondary: '#BDC3AC',
56+
colorBorderPrimary: '#212814',
57+
colorPrimary: '#B0D944'
58+
}
59+
}
60+
}),
61+
PageHeader: ({
62+
title,
63+
testID
64+
}: {
65+
title: React.ReactNode
66+
testID?: string
67+
}) => <h1 data-testid={testID}>{title}</h1>
68+
}
69+
})
70+
71+
jest.mock('@tetherto/pearpass-lib-ui-kit/icons', () => ({
72+
WatchLater: () => <span data-testid="icon-watch-later" />
73+
}))
74+
75+
const mockUseCountDown = jest.fn(
76+
(_opts: { initialSeconds: number; onFinish: () => void | Promise<void> }) =>
77+
'00:42'
78+
)
79+
80+
jest.mock('@tetherto/pear-apps-lib-ui-react-hooks', () => ({
81+
useCountDown: (opts: {
82+
initialSeconds: number
83+
onFinish: () => void | Promise<void>
84+
}) => mockUseCountDown(opts)
85+
}))
86+
87+
describe('LockedScreenV2', () => {
88+
beforeEach(() => {
89+
jest.clearAllMocks()
90+
mockUseCountDown.mockImplementation(() => '00:42')
91+
mockRefreshMasterPasswordStatus.mockResolvedValue({
92+
isLocked: true,
93+
lockoutRemainingMs: 60_000
94+
})
95+
})
96+
97+
it('renders headline, description, try-again label, and shell', async () => {
98+
render(<LockedScreenV2 />)
99+
100+
expect(screen.getByTestId('locked-screen-v2')).toBeInTheDocument()
101+
expect(screen.getByTestId('onboarding-shell-mock')).toBeInTheDocument()
102+
expect(screen.getByTestId('locked-screen-headline-v2').textContent).toBe(
103+
'PearPass locked'
104+
)
105+
expect(screen.getByTestId('locked-screen-desc-line1-v2').textContent).toBe(
106+
'Too many failed attempts.'
107+
)
108+
expect(screen.getByTestId('locked-screen-desc-line2-v2').textContent).toBe(
109+
'For your security, access is temporarily locked.'
110+
)
111+
expect(screen.getByTestId('locked-screen-try-label-v2').textContent).toBe(
112+
'Try again in'
113+
)
114+
115+
await waitFor(() => {
116+
expect(mockRefreshMasterPasswordStatus).toHaveBeenCalled()
117+
})
118+
await waitFor(() => {
119+
expect(screen.getByTestId('locked-screen-countdown-v2')).toBeInTheDocument()
120+
})
121+
})
122+
123+
it('shows countdown placeholder until status resolves with remaining lockout', async () => {
124+
let resolveStatus: (value: MasterPasswordLockStatus) => void = () => {}
125+
mockRefreshMasterPasswordStatus.mockImplementation(
126+
() =>
127+
new Promise<MasterPasswordLockStatus | undefined>((resolve) => {
128+
resolveStatus = resolve
129+
})
130+
)
131+
132+
render(<LockedScreenV2 />)
133+
134+
expect(
135+
screen.getByTestId('locked-screen-countdown-placeholder-v2')
136+
).toBeInTheDocument()
137+
expect(screen.queryByTestId('locked-screen-countdown-v2')).not.toBeInTheDocument()
138+
139+
act(() => {
140+
resolveStatus({ isLocked: true, lockoutRemainingMs: 30_000 })
141+
})
142+
143+
await waitFor(() => {
144+
expect(screen.getByTestId('locked-screen-countdown-v2')).toBeInTheDocument()
145+
})
146+
})
147+
148+
it('shows live countdown when lockout remaining is positive after load', async () => {
149+
render(<LockedScreenV2 />)
150+
151+
await waitFor(() => {
152+
expect(screen.getByTestId('locked-screen-countdown-v2')).toBeInTheDocument()
153+
})
154+
expect(screen.queryByTestId('locked-screen-countdown-placeholder-v2')).not.toBeInTheDocument()
155+
expect(screen.getByTestId('locked-screen-countdown-v2').textContent).toBe(
156+
'00:42'
157+
)
158+
})
159+
160+
it('passes ceil(seconds) from lockoutRemainingMs to useCountDown', async () => {
161+
mockRefreshMasterPasswordStatus.mockResolvedValue({
162+
isLocked: true,
163+
lockoutRemainingMs: 5500
164+
})
165+
166+
render(<LockedScreenV2 />)
167+
168+
await waitFor(() => {
169+
expect(mockUseCountDown).toHaveBeenCalled()
170+
})
171+
172+
expect(mockUseCountDown).toHaveBeenCalledWith(
173+
expect.objectContaining({ initialSeconds: 6 })
174+
)
175+
})
176+
177+
it('refreshes status on mount', async () => {
178+
render(<LockedScreenV2 />)
179+
180+
await waitFor(() => {
181+
expect(mockRefreshMasterPasswordStatus).toHaveBeenCalledTimes(1)
182+
})
183+
})
184+
185+
it('navigates to master password when countdown finishes and vault reports unlocked', async () => {
186+
let onFinish: (() => void | Promise<void>) | undefined
187+
188+
mockUseCountDown.mockImplementation(
189+
(opts: { initialSeconds: number; onFinish: () => void | Promise<void> }) => {
190+
onFinish = opts.onFinish
191+
return '00:01'
192+
}
193+
)
194+
195+
mockRefreshMasterPasswordStatus
196+
.mockResolvedValueOnce({
197+
isLocked: true,
198+
lockoutRemainingMs: 1000
199+
})
200+
.mockResolvedValueOnce({ isLocked: false })
201+
202+
render(<LockedScreenV2 />)
203+
204+
await waitFor(() => {
205+
expect(onFinish).toBeDefined()
206+
})
207+
208+
await act(async () => {
209+
await onFinish?.()
210+
})
211+
212+
expect(mockRefreshMasterPasswordStatus).toHaveBeenCalledTimes(2)
213+
expect(mockNavigate).toHaveBeenCalledWith('welcome', {
214+
state: NAVIGATION_ROUTES.MASTER_PASSWORD
215+
})
216+
})
217+
218+
it('does not navigate when status is still locked after countdown callback', async () => {
219+
let onFinish: (() => void | Promise<void>) | undefined
220+
221+
mockUseCountDown.mockImplementation(
222+
(opts: { initialSeconds: number; onFinish: () => void | Promise<void> }) => {
223+
onFinish = opts.onFinish
224+
return '00:01'
225+
}
226+
)
227+
228+
mockRefreshMasterPasswordStatus
229+
.mockResolvedValueOnce({
230+
isLocked: true,
231+
lockoutRemainingMs: 1000
232+
})
233+
.mockResolvedValueOnce({ isLocked: true })
234+
235+
render(<LockedScreenV2 />)
236+
237+
await waitFor(() => {
238+
expect(onFinish).toBeDefined()
239+
})
240+
241+
await act(async () => {
242+
await onFinish?.()
243+
})
244+
245+
expect(mockNavigate).not.toHaveBeenCalled()
246+
})
247+
248+
it('keeps placeholder when lockout remaining rounds to zero seconds', async () => {
249+
mockRefreshMasterPasswordStatus.mockResolvedValue({
250+
isLocked: true,
251+
lockoutRemainingMs: 0
252+
})
253+
254+
render(<LockedScreenV2 />)
255+
256+
await waitFor(() => {
257+
expect(mockRefreshMasterPasswordStatus).toHaveBeenCalled()
258+
})
259+
260+
expect(
261+
screen.getByTestId('locked-screen-countdown-placeholder-v2')
262+
).toBeInTheDocument()
263+
expect(mockUseCountDown).not.toHaveBeenCalled()
264+
})
265+
})

0 commit comments

Comments
 (0)