Skip to content

Commit 7fcaea5

Browse files
BillLeoutsakosvl346Bill Leoutsakoswaleedlatif1
authored
refactor(ui): share account headings and auth control sizing (#8074)
* improvement(ui): share account status headings * improvement(ui): use shared sizing for auth controls * refactor(emcn): share invalid OTP slot treatment (#8246) * Add shared invalid state to EMCN OTP slots * fix(ui): distinguish OTP verification from resend errors * test(ui): type OTP input queries as inputs * test(ui): import OTP test button prop type * test(ui): use shared logger mock in OTP regression --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Waleed <walif6@gmail.com>
1 parent 20312be commit 7fcaea5

25 files changed

Lines changed: 422 additions & 157 deletions

File tree

‎apps/sim/app/(auth)/components/auth-nav-prompt.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function AuthNavPrompt({ prompt, href, linkLabel, onNavigate }: AuthNavPr
1919
return (
2020
<div className='flex items-center justify-center gap-1 text-sm'>
2121
{prompt && <span className='text-[var(--text-muted)]'>{prompt}</span>}
22-
<ChipLink href={href} onClick={onNavigate} className='border border-[var(--border-1)]'>
22+
<ChipLink href={href} onClick={onNavigate} variant='outline'>
2323
{linkLabel}
2424
</ChipLink>
2525
</div>

‎apps/sim/app/(auth)/components/auth-submit-button.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { ReactNode } from 'react'
22
import { Chip, Loader } from '@sim/emcn'
3-
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
43

54
interface AuthSubmitButtonProps {
65
children: ReactNode
@@ -32,7 +31,8 @@ export function AuthSubmitButton({
3231
onClick={onClick}
3332
disabled={disabled || loading}
3433
fullWidth
35-
className={AUTH_BUTTON_CLASS}
34+
size='lg'
35+
align='center'
3636
>
3737
{loading ? (
3838
<span className='flex items-center gap-2'>

‎apps/sim/app/(auth)/components/constants.ts‎

Lines changed: 0 additions & 17 deletions
This file was deleted.

‎apps/sim/app/(auth)/components/password-input.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
'use client'
22

33
import { useState } from 'react'
4-
import { ChipInput, type ChipInputProps, cn } from '@sim/emcn'
4+
import { ChipInput, type ChipInputProps } from '@sim/emcn'
55
import { Eye, EyeOff } from '@sim/emcn/icons'
6-
import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants'
76

8-
type PasswordInputProps = Omit<ChipInputProps, 'type' | 'icon' | 'endAdornment'>
7+
type PasswordInputProps = Omit<ChipInputProps, 'type' | 'icon' | 'endAdornment' | 'size'>
98

109
/**
1110
* A {@link ChipInput} that owns the password reveal toggle — the eye button is
@@ -19,7 +18,8 @@ export function PasswordInput({ error, className, ...props }: PasswordInputProps
1918
return (
2019
<ChipInput
2120
{...props}
22-
className={cn(AUTH_CONTROL_HEIGHT, className)}
21+
size='lg'
22+
className={className}
2323
type={visible ? 'text' : 'password'}
2424
error={error}
2525
endAdornment={

‎apps/sim/app/(auth)/components/social-login-buttons.tsx‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
'use client'
22

33
import { type ReactNode, useState } from 'react'
4-
import { Chip, cn } from '@sim/emcn'
4+
import { Chip } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import { getErrorMessage } from '@sim/utils/errors'
77
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
88
import { client } from '@/lib/auth/auth-client'
99
import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'
10-
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
1110

1211
const logger = createLogger('SocialLoginButtons')
1312

@@ -73,7 +72,9 @@ export function SocialLoginButtons({
7372
<Chip
7473
fullWidth
7574
leftIcon={GithubIcon}
76-
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
75+
variant='outline'
76+
size='lg'
77+
align='center'
7778
disabled={!githubAvailable || isGithubLoading}
7879
onClick={signInWithGithub}
7980
>
@@ -85,7 +86,9 @@ export function SocialLoginButtons({
8586
<Chip
8687
fullWidth
8788
leftIcon={GoogleIcon}
88-
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
89+
variant='outline'
90+
size='lg'
91+
align='center'
8992
disabled={!googleAvailable || isGoogleLoading}
9093
onClick={signInWithGoogle}
9194
>
@@ -97,7 +100,9 @@ export function SocialLoginButtons({
97100
<Chip
98101
fullWidth
99102
leftIcon={MicrosoftIcon}
100-
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
103+
variant='outline'
104+
size='lg'
105+
align='center'
101106
disabled={!microsoftAvailable || isMicrosoftLoading}
102107
onClick={signInWithMicrosoft}
103108
>

‎apps/sim/app/(auth)/components/sso-login-button.tsx‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
'use client'
2-
import { Chip, cn } from '@sim/emcn'
2+
import { Chip } from '@sim/emcn'
33
import { useRouter } from 'next/navigation'
44
import { isSsoEnabled } from '@/lib/core/config/env-flags'
5-
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
65

76
interface SSOLoginButtonProps {
87
callbackURL?: string
@@ -28,14 +27,12 @@ export function SSOLoginButton({
2827

2928
return (
3029
<Chip
31-
variant={variant === 'primary' ? 'primary' : undefined}
30+
variant={variant}
3231
fullWidth
3332
onClick={handleSSOClick}
34-
className={cn(
35-
AUTH_BUTTON_CLASS,
36-
variant === 'outline' && 'border border-[var(--border-1)]',
37-
className
38-
)}
33+
size='lg'
34+
align='center'
35+
className={className}
3936
>
4037
Sign in with SSO
4138
</Chip>

‎apps/sim/app/(auth)/oauth/consent/consent-view.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
AuthSubmitButton,
1515
AuthTextLink,
1616
} from '@/app/(auth)/components'
17-
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
1817
import { OAuthConsentLoading } from '@/app/(auth)/oauth/consent/loading'
1918
import {
2019
useOAuthConsent,
@@ -166,7 +165,8 @@ export function OAuthConsentView({
166165
variant='border'
167166
fullWidth
168167
disabled={isPending}
169-
className={AUTH_BUTTON_CLASS}
168+
size='lg'
169+
align='center'
170170
onClick={() => decide(false)}
171171
>
172172
{consent.isPending && consent.variables === false ? 'Declining…' : 'Deny'}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/** @vitest-environment jsdom */
2+
import { act, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react'
3+
import { createRoot, type Root } from 'react-dom/client'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
chatRequest: vi.fn(),
8+
chatVerify: vi.fn(),
9+
fileRequest: vi.fn(),
10+
fileVerify: vi.fn(),
11+
refresh: vi.fn(),
12+
}))
13+
14+
vi.mock('@sim/emcn', () => ({
15+
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
16+
ChipInput: ({
17+
error: _error,
18+
size: _size,
19+
...props
20+
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> & {
21+
error?: boolean
22+
size?: string
23+
}) => <input {...props} />,
24+
Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
25+
<label htmlFor={htmlFor}>{children}</label>
26+
),
27+
InputOTP: ({
28+
children,
29+
value,
30+
onChange,
31+
'aria-invalid': invalid,
32+
}: {
33+
children: ReactNode
34+
value: string
35+
onChange: (value: string) => void
36+
'aria-invalid'?: boolean
37+
}) => (
38+
<div>
39+
<input
40+
data-testid='otp-code'
41+
aria-invalid={invalid}
42+
value={value}
43+
onChange={(event) => onChange(event.target.value)}
44+
/>
45+
{children}
46+
</div>
47+
),
48+
InputOTPGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
49+
InputOTPSlot: ({ invalid, index }: { invalid?: boolean; index: number }) => (
50+
<span data-otp-slot={index} data-invalid={invalid} />
51+
),
52+
}))
53+
vi.mock('@/lib/messaging/email/validation', () => ({
54+
quickValidateEmail: () => ({ isValid: true }),
55+
}))
56+
vi.mock('@/app/(auth)/components', () => ({
57+
AuthSubmitButton: ({
58+
children,
59+
loading: _loading,
60+
loadingLabel: _loadingLabel,
61+
...props
62+
}: ButtonHTMLAttributes<HTMLButtonElement> & {
63+
loading?: boolean
64+
loadingLabel?: string
65+
}) => (
66+
<button type='submit' {...props}>
67+
{children}
68+
</button>
69+
),
70+
}))
71+
vi.mock('@/app/(auth)/components/auth-button-classes', () => ({ AUTH_TEXT_LINK: '' }))
72+
vi.mock('@/components/auth/public-auth-header', () => ({
73+
PublicAuthHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
74+
}))
75+
vi.mock('@/app/f/[token]/public-file-auth-shell', () => ({
76+
PublicFileAuthShell: ({ children }: { children: ReactNode }) => <div>{children}</div>,
77+
}))
78+
vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) }))
79+
vi.mock('@/hooks/queries/chats', () => ({
80+
useChatEmailOtpRequest: () => ({ mutateAsync: mocks.chatRequest, isPending: false }),
81+
useChatEmailOtpVerify: () => ({ mutateAsync: mocks.chatVerify, isPending: false }),
82+
}))
83+
vi.mock('@/hooks/queries/public-shares', () => ({
84+
usePublicFileOtpRequest: () => ({ mutateAsync: mocks.fileRequest, isPending: false }),
85+
usePublicFileOtpVerify: () => ({ mutateAsync: mocks.fileVerify, isPending: false }),
86+
}))
87+
88+
import EmailAuth from '@/app/(interfaces)/chat/components/auth/email/email-auth'
89+
import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth'
90+
91+
let root: Root
92+
let container: HTMLDivElement
93+
94+
function changeInput(input: HTMLInputElement, value: string) {
95+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
96+
input.dispatchEvent(new Event('input', { bubbles: true }))
97+
}
98+
99+
function button(label: string) {
100+
const found = Array.from(container.querySelectorAll('button')).find(
101+
(candidate) => candidate.textContent?.trim() === label
102+
)
103+
if (!found) throw new Error(`Missing button: ${label}`)
104+
return found
105+
}
106+
107+
function expectOtpInvalid(invalid: boolean) {
108+
expect(container.querySelector('[data-testid="otp-code"]')?.getAttribute('aria-invalid')).toBe(
109+
String(invalid)
110+
)
111+
const slots = container.querySelectorAll('[data-otp-slot]')
112+
expect(slots).toHaveLength(6)
113+
for (const slot of slots) expect(slot.getAttribute('data-invalid')).toBe(String(invalid))
114+
}
115+
116+
beforeEach(() => {
117+
vi.clearAllMocks()
118+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
119+
mocks.chatRequest.mockResolvedValue({})
120+
mocks.chatVerify.mockResolvedValue({})
121+
mocks.fileRequest.mockResolvedValue({})
122+
mocks.fileVerify.mockResolvedValue({})
123+
container = document.createElement('div')
124+
document.body.appendChild(container)
125+
root = createRoot(container)
126+
})
127+
128+
afterEach(() => {
129+
act(() => root.unmount())
130+
container.remove()
131+
vi.unstubAllGlobals()
132+
})
133+
134+
describe('OTP error provenance', () => {
135+
it('keeps the chat code valid on resend failure and marks only a failed verification invalid', async () => {
136+
act(() => root.render(<EmailAuth identifier='chat-1' />))
137+
act(() =>
138+
changeInput(container.querySelector<HTMLInputElement>('#email')!, 'member@example.com')
139+
)
140+
await act(async () => button('Continue').click())
141+
142+
mocks.chatRequest.mockRejectedValueOnce(new Error('Delivery failed'))
143+
await act(async () => button('Resend').click())
144+
expect(container.textContent).toContain('Delivery failed')
145+
expectOtpInvalid(false)
146+
147+
mocks.chatVerify.mockRejectedValueOnce(new Error('Incorrect code'))
148+
await act(async () =>
149+
changeInput(container.querySelector<HTMLInputElement>('[data-testid="otp-code"]')!, '123456')
150+
)
151+
expect(container.textContent).toContain('Incorrect code')
152+
expectOtpInvalid(true)
153+
})
154+
155+
it('keeps the public-file code valid on resend failure and marks only a failed verification invalid', async () => {
156+
act(() => root.render(<PublicFileEmailAuth token='share-1' />))
157+
act(() =>
158+
changeInput(container.querySelector<HTMLInputElement>('#email')!, 'member@example.com')
159+
)
160+
await act(async () => button('Continue').click())
161+
162+
mocks.fileRequest.mockRejectedValueOnce(new Error('Delivery failed'))
163+
await act(async () => button('Resend').click())
164+
expect(container.textContent).toContain('Delivery failed')
165+
expectOtpInvalid(false)
166+
167+
mocks.fileVerify.mockRejectedValueOnce(new Error('Incorrect code'))
168+
await act(async () =>
169+
changeInput(container.querySelector<HTMLInputElement>('[data-testid="otp-code"]')!, '123456')
170+
)
171+
expect(container.textContent).toContain('Incorrect code')
172+
expectOtpInvalid(true)
173+
})
174+
})

‎apps/sim/app/(auth)/verify/verify-content.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { Suspense, useEffect, useState } from 'react'
4-
import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn'
4+
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn'
55
import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
66
import {
77
AuthFormMessage,
@@ -84,14 +84,16 @@ function VerificationForm({
8484
</p>
8585

8686
<div className='flex justify-center'>
87-
<InputOTP maxLength={6} value={otp} onChange={handleOtpChange} disabled={isLoading}>
87+
<InputOTP
88+
maxLength={6}
89+
value={otp}
90+
onChange={handleOtpChange}
91+
disabled={isLoading}
92+
aria-invalid={isInvalidOtp}
93+
>
8894
<InputOTPGroup>
8995
{OTP_SLOTS.map((index) => (
90-
<InputOTPSlot
91-
key={index}
92-
index={index}
93-
className={cn(isInvalidOtp && 'border-[var(--text-error)]')}
94-
/>
96+
<InputOTPSlot key={index} index={index} invalid={isInvalidOtp} />
9597
))}
9698
</InputOTPGroup>
9799
</InputOTP>

0 commit comments

Comments
 (0)