From d2f04f1a99352348f8710d201c5efdbf7398c0c0 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 22 Sep 2026 13:29:19 -0700 Subject: [PATCH 1/3] improvement(ui): share account status headings --- .../app/(auth)/components/auth-nav-prompt.tsx | 2 +- .../components/social-login-buttons.tsx | 11 ++-- .../(auth)/components/sso-login-button.tsx | 8 +-- apps/sim/app/invite/components/index.ts | 1 + .../app/invite/components/invite-heading.tsx | 16 ++++++ .../sim/app/invite/components/status-card.tsx | 16 +++--- apps/sim/app/unsubscribe/unsubscribe.tsx | 51 ++++++++----------- 7 files changed, 55 insertions(+), 50 deletions(-) create mode 100644 apps/sim/app/invite/components/invite-heading.tsx diff --git a/apps/sim/app/(auth)/components/auth-nav-prompt.tsx b/apps/sim/app/(auth)/components/auth-nav-prompt.tsx index d479a273b4a..bdea32fa50f 100644 --- a/apps/sim/app/(auth)/components/auth-nav-prompt.tsx +++ b/apps/sim/app/(auth)/components/auth-nav-prompt.tsx @@ -19,7 +19,7 @@ export function AuthNavPrompt({ prompt, href, linkLabel, onNavigate }: AuthNavPr return (
{prompt && {prompt}} - + {linkLabel}
diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index 37df7815ed7..8e701629cf8 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -1,7 +1,7 @@ 'use client' import { type ReactNode, useState } from 'react' -import { Chip, cn } from '@sim/emcn' +import { Chip } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons' @@ -73,7 +73,8 @@ export function SocialLoginButtons({ @@ -85,7 +86,8 @@ export function SocialLoginButtons({ @@ -97,7 +99,8 @@ export function SocialLoginButtons({ diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx index 1bbd06d5591..152abcdd17e 100644 --- a/apps/sim/app/(auth)/components/sso-login-button.tsx +++ b/apps/sim/app/(auth)/components/sso-login-button.tsx @@ -28,14 +28,10 @@ export function SSOLoginButton({ return ( Sign in with SSO diff --git a/apps/sim/app/invite/components/index.ts b/apps/sim/app/invite/components/index.ts index f72433bf8a8..817d145aaae 100644 --- a/apps/sim/app/invite/components/index.ts +++ b/apps/sim/app/invite/components/index.ts @@ -1,4 +1,5 @@ export { InvitationDisclosure } from '@/app/invite/components/invitation-disclosure' export { InvitationWorkspaceAccess } from '@/app/invite/components/invitation-workspace-access' +export { InviteHeading } from '@/app/invite/components/invite-heading' export { default as InviteLayout } from '@/app/invite/components/layout' export { InviteStatusCard } from '@/app/invite/components/status-card' diff --git a/apps/sim/app/invite/components/invite-heading.tsx b/apps/sim/app/invite/components/invite-heading.tsx new file mode 100644 index 00000000000..c2b54626498 --- /dev/null +++ b/apps/sim/app/invite/components/invite-heading.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' + +interface InviteHeadingProps { + title: ReactNode + children: ReactNode +} + +/** Heading shared by invitation and email-preference states; subcopy stays with each caller. */ +export function InviteHeading({ title, children }: InviteHeadingProps) { + return ( +
+

{title}

+ {children} +
+ ) +} diff --git a/apps/sim/app/invite/components/status-card.tsx b/apps/sim/app/invite/components/status-card.tsx index 282ec08d7d1..aed182d7fa3 100644 --- a/apps/sim/app/invite/components/status-card.tsx +++ b/apps/sim/app/invite/components/status-card.tsx @@ -1,7 +1,8 @@ 'use client' -import { Chip, cn, Loader } from '@sim/emcn' +import { Chip, Loader } from '@sim/emcn' import { AuthSubmitButton } from '@/app/(auth)/components' import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' +import { InviteHeading } from '@/app/invite/components/invite-heading' /** A document navigation, so the marketing surface initializes its own theme store. */ function returnHome(): void { @@ -37,10 +38,9 @@ export function InviteStatusCard({ if (type === 'loading') { return ( <> -
-

Loading

+

{description}

-
+
@@ -50,10 +50,9 @@ export function InviteStatusCard({ return ( <> -
-

{title}

+

{description}

-
+
{details} @@ -81,7 +80,8 @@ export function InviteStatusCard({ fullWidth onClick={action.onClick} disabled={action.disabled || action.loading} - className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border)]')} + variant='outline' + className={AUTH_BUTTON_CLASS} > {action.loading ? ( diff --git a/apps/sim/app/unsubscribe/unsubscribe.tsx b/apps/sim/app/unsubscribe/unsubscribe.tsx index 84004b7827a..a9e086bb033 100644 --- a/apps/sim/app/unsubscribe/unsubscribe.tsx +++ b/apps/sim/app/unsubscribe/unsubscribe.tsx @@ -1,13 +1,13 @@ 'use client' import { Suspense } from 'react' -import { Chip, cn, Loader } from '@sim/emcn' +import { Chip, Loader } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useSearchParams } from 'next/navigation' import type { UnsubscribeType } from '@/lib/api/contracts/user' import { AuthSubmitButton } from '@/app/(auth)/components' import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' -import { InviteLayout } from '@/app/invite/components' +import { InviteHeading, InviteLayout } from '@/app/invite/components' import { useUnsubscribe, useUnsubscribeMutation } from '@/hooks/queries/unsubscribe' function UnsubscribeContent() { @@ -39,10 +39,9 @@ function UnsubscribeContent() { if (loading) { return ( -
-

Loading

+

Validating your unsubscribe link…

-
+
@@ -53,12 +52,9 @@ function UnsubscribeContent() { if (error) { return ( -
-

- Invalid Unsubscribe Link -

+

{error}

-
+
window.history.back()} loadingLabel=''> @@ -72,15 +68,12 @@ function UnsubscribeContent() { if (data?.isTransactional) { return ( -
-

- Important Account Emails -

+

Transactional emails like password resets, account confirmations, and security alerts cannot be unsubscribed from as they contain essential information for your account.

-
+
window.close()} loadingLabel=''> @@ -94,15 +87,12 @@ function UnsubscribeContent() { if (unsubscribed) { return ( -
-

- Successfully Unsubscribed -

+

You have been unsubscribed from our emails. You will stop receiving emails within 48 hours.

-
+
window.close()} loadingLabel=''> @@ -117,15 +107,12 @@ function UnsubscribeContent() { return ( -
-

- Email Preferences -

+

Choose which emails you'd like to stop receiving.

{data?.email}

-
+
{data?.currentPreferences.unsubscribeMarketing ? 'Unsubscribed from Marketing' @@ -167,7 +155,8 @@ function UnsubscribeContent() { isAlreadyUnsubscribedFromAll || data?.currentPreferences.unsubscribeUpdates } - className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')} + variant='outline' + className={AUTH_BUTTON_CLASS} > {data?.currentPreferences.unsubscribeUpdates ? 'Unsubscribed from Updates' @@ -182,7 +171,8 @@ function UnsubscribeContent() { isAlreadyUnsubscribedFromAll || data?.currentPreferences.unsubscribeNotifications } - className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')} + variant='outline' + className={AUTH_BUTTON_CLASS} > {data?.currentPreferences.unsubscribeNotifications ? 'Unsubscribed from Notifications' @@ -205,10 +195,9 @@ export default function Unsubscribe() { -
-

Loading

+

Validating your unsubscribe link…

-
+
From 4c26fc6e1e9409c6c2bfcff11ea6880c84106046 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 22 Sep 2026 14:30:35 -0700 Subject: [PATCH 2/3] improvement(ui): use shared sizing for auth controls --- .../(auth)/components/auth-submit-button.tsx | 4 ++-- apps/sim/app/(auth)/components/constants.ts | 17 ----------------- .../app/(auth)/components/password-input.tsx | 8 ++++---- .../(auth)/components/social-login-buttons.tsx | 10 ++++++---- .../app/(auth)/components/sso-login-button.tsx | 7 ++++--- .../app/(auth)/oauth/consent/consent-view.tsx | 4 ++-- .../chat/components/auth/email/email-auth.tsx | 2 +- .../components/auth/password/password-auth.tsx | 1 - apps/sim/app/f/[token]/public-file-auth.tsx | 1 - .../app/f/[token]/public-file-email-auth.tsx | 2 +- apps/sim/app/f/[token]/public-file-sso-auth.tsx | 2 +- apps/sim/app/invite/components/status-card.tsx | 4 ++-- apps/sim/app/unsubscribe/unsubscribe.tsx | 10 ++++++---- .../slack-setup-wizard/slack-setup-wizard.tsx | 4 ++-- apps/sim/ee/sso/components/sso-auth.tsx | 2 +- apps/sim/ee/sso/components/sso-form.test.tsx | 13 +++++++------ apps/sim/ee/sso/components/sso-form.tsx | 16 +++++++++------- 17 files changed, 48 insertions(+), 59 deletions(-) delete mode 100644 apps/sim/app/(auth)/components/constants.ts diff --git a/apps/sim/app/(auth)/components/auth-submit-button.tsx b/apps/sim/app/(auth)/components/auth-submit-button.tsx index 5d37ecb12ea..139bec31892 100644 --- a/apps/sim/app/(auth)/components/auth-submit-button.tsx +++ b/apps/sim/app/(auth)/components/auth-submit-button.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react' import { Chip, Loader } from '@sim/emcn' -import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' interface AuthSubmitButtonProps { children: ReactNode @@ -32,7 +31,8 @@ export function AuthSubmitButton({ onClick={onClick} disabled={disabled || loading} fullWidth - className={AUTH_BUTTON_CLASS} + size='lg' + align='center' > {loading ? ( diff --git a/apps/sim/app/(auth)/components/constants.ts b/apps/sim/app/(auth)/components/constants.ts deleted file mode 100644 index beb515c525d..00000000000 --- a/apps/sim/app/(auth)/components/constants.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Auth and invite surfaces use a slightly taller control than the 30px chip - * default, matching the landing `HeroCta` field family (the landing's own - * auth-adjacent CTA renders taller fields than in-app chips). Applied as the - * single source of truth for every auth field and button height so the inputs, - * submit, social, SSO, and invite action buttons stay on one line. - */ -export const AUTH_CONTROL_HEIGHT = 'h-9' - -/** - * Shared layout for full-width auth/invite chip buttons (submit, social, SSO, - * invite actions). `[&>span]:flex-none` collapses the chip's stretching label - * span — which carries `flex-1` — so the icon + label cluster truly centers - * under `justify-center` (the landing `HeroCta` idiom). Height-only inputs use - * {@link AUTH_CONTROL_HEIGHT}; buttons compose this on top of it. - */ -export const AUTH_BUTTON_CLASS = `${AUTH_CONTROL_HEIGHT} justify-center [&>span]:flex-none` diff --git a/apps/sim/app/(auth)/components/password-input.tsx b/apps/sim/app/(auth)/components/password-input.tsx index f62e9c23ad3..e1c4867f24e 100644 --- a/apps/sim/app/(auth)/components/password-input.tsx +++ b/apps/sim/app/(auth)/components/password-input.tsx @@ -1,11 +1,10 @@ 'use client' import { useState } from 'react' -import { ChipInput, type ChipInputProps, cn } from '@sim/emcn' +import { ChipInput, type ChipInputProps } from '@sim/emcn' import { Eye, EyeOff } from '@sim/emcn/icons' -import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants' -type PasswordInputProps = Omit +type PasswordInputProps = Omit /** * A {@link ChipInput} that owns the password reveal toggle — the eye button is @@ -19,7 +18,8 @@ export function PasswordInput({ error, className, ...props }: PasswordInputProps return ( @@ -87,7 +87,8 @@ export function SocialLoginButtons({ fullWidth leftIcon={GoogleIcon} variant='outline' - className={AUTH_BUTTON_CLASS} + size='lg' + align='center' disabled={!googleAvailable || isGoogleLoading} onClick={signInWithGoogle} > @@ -100,7 +101,8 @@ export function SocialLoginButtons({ fullWidth leftIcon={MicrosoftIcon} variant='outline' - className={AUTH_BUTTON_CLASS} + size='lg' + align='center' disabled={!microsoftAvailable || isMicrosoftLoading} onClick={signInWithMicrosoft} > diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx index 152abcdd17e..03ac768b86f 100644 --- a/apps/sim/app/(auth)/components/sso-login-button.tsx +++ b/apps/sim/app/(auth)/components/sso-login-button.tsx @@ -1,8 +1,7 @@ 'use client' -import { Chip, cn } from '@sim/emcn' +import { Chip } from '@sim/emcn' import { useRouter } from 'next/navigation' import { isSsoEnabled } from '@/lib/core/config/env-flags' -import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' interface SSOLoginButtonProps { callbackURL?: string @@ -31,7 +30,9 @@ export function SSOLoginButton({ variant={variant} fullWidth onClick={handleSSOClick} - className={cn(AUTH_BUTTON_CLASS, className)} + size='lg' + align='center' + className={className} > Sign in with SSO diff --git a/apps/sim/app/(auth)/oauth/consent/consent-view.tsx b/apps/sim/app/(auth)/oauth/consent/consent-view.tsx index b083ef34f92..460cecf107d 100644 --- a/apps/sim/app/(auth)/oauth/consent/consent-view.tsx +++ b/apps/sim/app/(auth)/oauth/consent/consent-view.tsx @@ -14,7 +14,6 @@ import { AuthSubmitButton, AuthTextLink, } from '@/app/(auth)/components' -import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' import { OAuthConsentLoading } from '@/app/(auth)/oauth/consent/loading' import { useOAuthConsent, @@ -166,7 +165,8 @@ export function OAuthConsentView({ variant='border' fullWidth disabled={isPending} - className={AUTH_BUTTON_CLASS} + size='lg' + align='center' onClick={() => decide(false)} > {consent.isPending && consent.variables === false ? 'Declining…' : 'Deny'} diff --git a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx index 7972106bfc9..2781d70779b 100644 --- a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx +++ b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx @@ -143,7 +143,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { autoCorrect='off' value={email} onChange={handleEmailChange} - className='h-[34px]' + size='lg' error={Boolean(hasEmailError)} /> {hasEmailError && ( diff --git a/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx index cd49a26fa28..36467bc6287 100644 --- a/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx +++ b/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx @@ -72,7 +72,6 @@ export default function PasswordAuth({ identifier }: PasswordAuthProps) { placeholder='Enter password' value={password} onChange={handlePasswordChange} - className='h-[34px]' error={hasPasswordError} />
{error ?

{error}

: null} diff --git a/apps/sim/app/f/[token]/public-file-email-auth.tsx b/apps/sim/app/f/[token]/public-file-email-auth.tsx index 8d237ed9ff5..a674db4ffcb 100644 --- a/apps/sim/app/f/[token]/public-file-email-auth.tsx +++ b/apps/sim/app/f/[token]/public-file-email-auth.tsx @@ -104,7 +104,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { setEmail(e.target.value) setError(null) }} - className='h-[34px]' + size='lg' error={Boolean(error)} /> {error ?

{error}

: null} diff --git a/apps/sim/app/f/[token]/public-file-sso-auth.tsx b/apps/sim/app/f/[token]/public-file-sso-auth.tsx index 26ca1961483..20b38e83579 100644 --- a/apps/sim/app/f/[token]/public-file-sso-auth.tsx +++ b/apps/sim/app/f/[token]/public-file-sso-auth.tsx @@ -82,7 +82,7 @@ export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) { setEmail(e.target.value) setError(null) }} - className='h-[34px]' + size='lg' error={Boolean(error)} /> {error ?

{error}

: null} diff --git a/apps/sim/app/invite/components/status-card.tsx b/apps/sim/app/invite/components/status-card.tsx index aed182d7fa3..403e64fdb1e 100644 --- a/apps/sim/app/invite/components/status-card.tsx +++ b/apps/sim/app/invite/components/status-card.tsx @@ -1,7 +1,6 @@ 'use client' import { Chip, Loader } from '@sim/emcn' import { AuthSubmitButton } from '@/app/(auth)/components' -import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' import { InviteHeading } from '@/app/invite/components/invite-heading' /** A document navigation, so the marketing surface initializes its own theme store. */ @@ -81,7 +80,8 @@ export function InviteStatusCard({ onClick={action.onClick} disabled={action.disabled || action.loading} variant='outline' - className={AUTH_BUTTON_CLASS} + size='lg' + align='center' > {action.loading ? ( diff --git a/apps/sim/app/unsubscribe/unsubscribe.tsx b/apps/sim/app/unsubscribe/unsubscribe.tsx index a9e086bb033..34083d2b4ee 100644 --- a/apps/sim/app/unsubscribe/unsubscribe.tsx +++ b/apps/sim/app/unsubscribe/unsubscribe.tsx @@ -6,7 +6,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { useSearchParams } from 'next/navigation' import type { UnsubscribeType } from '@/lib/api/contracts/user' import { AuthSubmitButton } from '@/app/(auth)/components' -import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' import { InviteHeading, InviteLayout } from '@/app/invite/components' import { useUnsubscribe, useUnsubscribeMutation } from '@/hooks/queries/unsubscribe' @@ -140,7 +139,8 @@ function UnsubscribeContent() { data?.currentPreferences.unsubscribeMarketing } variant='outline' - className={AUTH_BUTTON_CLASS} + size='lg' + align='center' > {data?.currentPreferences.unsubscribeMarketing ? 'Unsubscribed from Marketing' @@ -156,7 +156,8 @@ function UnsubscribeContent() { data?.currentPreferences.unsubscribeUpdates } variant='outline' - className={AUTH_BUTTON_CLASS} + size='lg' + align='center' > {data?.currentPreferences.unsubscribeUpdates ? 'Unsubscribed from Updates' @@ -172,7 +173,8 @@ function UnsubscribeContent() { data?.currentPreferences.unsubscribeNotifications } variant='outline' - className={AUTH_BUTTON_CLASS} + size='lg' + align='center' > {data?.currentPreferences.unsubscribeNotifications ? 'Unsubscribed from Notifications' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx index 4ffb44d9739..f2d0fb0d001 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx @@ -226,7 +226,7 @@ function StepConfigure({ onChange={(e) => onAppNameChange(e.target.value)} disabled={disabled} placeholder={DEFAULT_APP_NAME} - className='h-9' + size='lg' />
@@ -416,7 +416,7 @@ function SecretField({ id, label, value, onChange, disabled, placeholder }: Secr onChange={onChange} disabled={disabled} placeholder={placeholder} - className='h-9' + size='lg' />
) diff --git a/apps/sim/ee/sso/components/sso-auth.tsx b/apps/sim/ee/sso/components/sso-auth.tsx index 855651edaed..0b8deb58c9e 100644 --- a/apps/sim/ee/sso/components/sso-auth.tsx +++ b/apps/sim/ee/sso/components/sso-auth.tsx @@ -127,7 +127,7 @@ export default function SSOAuth({ identifier }: SSOAuthProps) { value={email} onChange={handleEmailChange} onKeyDown={handleKeyDown} - className='h-[34px]' + size='lg' error={showEmailValidationError && emailErrors.length > 0} /> {showEmailValidationError && emailErrors.length > 0 && ( diff --git a/apps/sim/ee/sso/components/sso-form.test.tsx b/apps/sim/ee/sso/components/sso-form.test.tsx index 369ef6a165b..9f71416d717 100644 --- a/apps/sim/ee/sso/components/sso-form.test.tsx +++ b/apps/sim/ee/sso/components/sso-form.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react' +import { act, type InputHTMLAttributes, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { renderToString } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,15 +26,16 @@ vi.mock('next/link', () => ({ })) vi.mock('@sim/emcn', () => ({ - Button: ({ children, ...props }: ButtonHTMLAttributes) => ( - + ChipLink: ({ href, children }: { href: string; children?: ReactNode }) => ( + {children} ), ChipInput: ({ error: _error, + size: _size, ...props - }: InputHTMLAttributes & { error?: boolean }) => , + }: Omit, 'size'> & { error?: boolean; size?: string }) => ( + + ), Label: ({ children }: { children?: ReactNode }) => {children}, cn: (...values: unknown[]) => values.filter(Boolean).join(' '), })) diff --git a/apps/sim/ee/sso/components/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx index 9d9f39d10d1..369de48c16d 100644 --- a/apps/sim/ee/sso/components/sso-form.tsx +++ b/apps/sim/ee/sso/components/sso-form.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { Button, ChipInput, Label } from '@sim/emcn' +import { ChipInput, ChipLink, Label } from '@sim/emcn' import { createLogger } from '@sim/logger' import Link from 'next/link' import { useSearchParams } from 'next/navigation' @@ -213,7 +213,7 @@ function SSOFormContent({ onChange={handleEmailChange} aria-invalid={hasEmailError || undefined} aria-describedby={hasEmailError ? 'sso-email-errors' : undefined} - className='h-[34px]' + size='lg' error={Boolean(hasEmailError)} /> {hasEmailError && ( @@ -247,13 +247,15 @@ function SSOFormContent({
- - - + Sign in with email +
)} From d7f8b69ad190b8524bd37d5340f30116b5680388 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:10:38 -0700 Subject: [PATCH 3/3] 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 --- .../(auth)/verify/otp-error-source.test.tsx | 174 ++++++++++++++++++ apps/sim/app/(auth)/verify/verify-content.tsx | 16 +- .../chat/components/auth/email/email-auth.tsx | 27 ++- .../app/f/[token]/public-file-email-auth.tsx | 35 ++-- packages/emcn/src/components/index.ts | 8 +- .../components/input-otp/input-otp.test.tsx | 66 +++++++ .../src/components/input-otp/input-otp.tsx | 57 +++--- 7 files changed, 327 insertions(+), 56 deletions(-) create mode 100644 apps/sim/app/(auth)/verify/otp-error-source.test.tsx create mode 100644 packages/emcn/src/components/input-otp/input-otp.test.tsx diff --git a/apps/sim/app/(auth)/verify/otp-error-source.test.tsx b/apps/sim/app/(auth)/verify/otp-error-source.test.tsx new file mode 100644 index 00000000000..f81caafe77f --- /dev/null +++ b/apps/sim/app/(auth)/verify/otp-error-source.test.tsx @@ -0,0 +1,174 @@ +/** @vitest-environment jsdom */ +import { act, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + chatRequest: vi.fn(), + chatVerify: vi.fn(), + fileRequest: vi.fn(), + fileVerify: vi.fn(), + refresh: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + cn: (...values: Array) => values.filter(Boolean).join(' '), + ChipInput: ({ + error: _error, + size: _size, + ...props + }: Omit, 'size'> & { + error?: boolean + size?: string + }) => , + Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => ( + + ), + InputOTP: ({ + children, + value, + onChange, + 'aria-invalid': invalid, + }: { + children: ReactNode + value: string + onChange: (value: string) => void + 'aria-invalid'?: boolean + }) => ( +
+ onChange(event.target.value)} + /> + {children} +
+ ), + InputOTPGroup: ({ children }: { children: ReactNode }) =>
{children}
, + InputOTPSlot: ({ invalid, index }: { invalid?: boolean; index: number }) => ( + + ), +})) +vi.mock('@/lib/messaging/email/validation', () => ({ + quickValidateEmail: () => ({ isValid: true }), +})) +vi.mock('@/app/(auth)/components', () => ({ + AuthSubmitButton: ({ + children, + loading: _loading, + loadingLabel: _loadingLabel, + ...props + }: ButtonHTMLAttributes & { + loading?: boolean + loadingLabel?: string + }) => ( + + ), +})) +vi.mock('@/app/(auth)/components/auth-button-classes', () => ({ AUTH_TEXT_LINK: '' })) +vi.mock('@/components/auth/public-auth-header', () => ({ + PublicAuthHeader: ({ title }: { title: string }) =>

{title}

, +})) +vi.mock('@/app/f/[token]/public-file-auth-shell', () => ({ + PublicFileAuthShell: ({ children }: { children: ReactNode }) =>
{children}
, +})) +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) })) +vi.mock('@/hooks/queries/chats', () => ({ + useChatEmailOtpRequest: () => ({ mutateAsync: mocks.chatRequest, isPending: false }), + useChatEmailOtpVerify: () => ({ mutateAsync: mocks.chatVerify, isPending: false }), +})) +vi.mock('@/hooks/queries/public-shares', () => ({ + usePublicFileOtpRequest: () => ({ mutateAsync: mocks.fileRequest, isPending: false }), + usePublicFileOtpVerify: () => ({ mutateAsync: mocks.fileVerify, isPending: false }), +})) + +import EmailAuth from '@/app/(interfaces)/chat/components/auth/email/email-auth' +import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth' + +let root: Root +let container: HTMLDivElement + +function changeInput(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function button(label: string) { + const found = Array.from(container.querySelectorAll('button')).find( + (candidate) => candidate.textContent?.trim() === label + ) + if (!found) throw new Error(`Missing button: ${label}`) + return found +} + +function expectOtpInvalid(invalid: boolean) { + expect(container.querySelector('[data-testid="otp-code"]')?.getAttribute('aria-invalid')).toBe( + String(invalid) + ) + const slots = container.querySelectorAll('[data-otp-slot]') + expect(slots).toHaveLength(6) + for (const slot of slots) expect(slot.getAttribute('data-invalid')).toBe(String(invalid)) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.chatRequest.mockResolvedValue({}) + mocks.chatVerify.mockResolvedValue({}) + mocks.fileRequest.mockResolvedValue({}) + mocks.fileVerify.mockResolvedValue({}) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +describe('OTP error provenance', () => { + it('keeps the chat code valid on resend failure and marks only a failed verification invalid', async () => { + act(() => root.render()) + act(() => + changeInput(container.querySelector('#email')!, 'member@example.com') + ) + await act(async () => button('Continue').click()) + + mocks.chatRequest.mockRejectedValueOnce(new Error('Delivery failed')) + await act(async () => button('Resend').click()) + expect(container.textContent).toContain('Delivery failed') + expectOtpInvalid(false) + + mocks.chatVerify.mockRejectedValueOnce(new Error('Incorrect code')) + await act(async () => + changeInput(container.querySelector('[data-testid="otp-code"]')!, '123456') + ) + expect(container.textContent).toContain('Incorrect code') + expectOtpInvalid(true) + }) + + it('keeps the public-file code valid on resend failure and marks only a failed verification invalid', async () => { + act(() => root.render()) + act(() => + changeInput(container.querySelector('#email')!, 'member@example.com') + ) + await act(async () => button('Continue').click()) + + mocks.fileRequest.mockRejectedValueOnce(new Error('Delivery failed')) + await act(async () => button('Resend').click()) + expect(container.textContent).toContain('Delivery failed') + expectOtpInvalid(false) + + mocks.fileVerify.mockRejectedValueOnce(new Error('Incorrect code')) + await act(async () => + changeInput(container.querySelector('[data-testid="otp-code"]')!, '123456') + ) + expect(container.textContent).toContain('Incorrect code') + expectOtpInvalid(true) + }) +}) diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index 88d1c2e9d67..4e13de53044 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -1,7 +1,7 @@ 'use client' import { Suspense, useEffect, useState } from 'react' -import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn' +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn' import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' import { AuthFormMessage, @@ -84,14 +84,16 @@ function VerificationForm({

- + {OTP_SLOTS.map((index) => ( - + ))} diff --git a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx index 2781d70779b..190dfbaf917 100644 --- a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx +++ b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx @@ -34,13 +34,17 @@ const validateEmailField = (emailValue: string): string[] => { export default function EmailAuth({ identifier }: EmailAuthProps) { const [email, setEmail] = useState('') - const [authError, setAuthError] = useState(null) + const [authError, setAuthError] = useState<{ + kind: 'verification' | 'request' + message: string + } | null>(null) const [emailErrors, setEmailErrors] = useState([]) const hasEmailError = emailErrors.length > 0 const [showOtpVerification, setShowOtpVerification] = useState(false) const [otpValue, setOtpValue] = useState('') const [countdown, setCountdown] = useState(0) + const isInvalidOtp = authError?.kind === 'verification' const requestOtp = useChatEmailOtpRequest(identifier) const verifyOtp = useChatEmailOtpVerify(identifier) @@ -89,7 +93,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { await verifyOtp.mutateAsync({ email, otp: codeToVerify }) } catch (error) { logger.error('Error verifying OTP:', error) - setAuthError(toError(error).message || 'Invalid verification code') + setAuthError({ + kind: 'verification', + message: toError(error).message || 'Invalid verification code', + }) } } @@ -102,7 +109,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { setOtpValue('') } catch (error) { logger.error('Error resending OTP:', error) - setAuthError(toError(error).message || 'Failed to resend verification code') + setAuthError({ + kind: 'request', + message: toError(error).message || 'Failed to resend verification code', + }) setCountdown(0) } } @@ -181,15 +191,12 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { } }} disabled={verifyOtp.isPending} - className={cn('gap-2', authError && 'otp-error')} + className={cn('gap-2', isInvalidOtp && 'otp-error')} + aria-invalid={isInvalidOtp} > {[0, 1, 2, 3, 4, 5].map((index) => ( - + ))} @@ -197,7 +204,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { {authError && (
-

{authError}

+

{authError.message}

)} diff --git a/apps/sim/app/f/[token]/public-file-email-auth.tsx b/apps/sim/app/f/[token]/public-file-email-auth.tsx index a674db4ffcb..198fea32ae5 100644 --- a/apps/sim/app/f/[token]/public-file-email-auth.tsx +++ b/apps/sim/app/f/[token]/public-file-email-auth.tsx @@ -28,8 +28,12 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { const [email, setEmail] = useState('') const [otp, setOtp] = useState('') const [sent, setSent] = useState(false) - const [error, setError] = useState(null) + const [error, setError] = useState<{ + kind: 'verification' | 'request' + message: string + } | null>(null) const [countdown, setCountdown] = useState(0) + const isInvalidOtp = error?.kind === 'verification' useEffect(() => { if (countdown <= 0) return @@ -39,7 +43,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { const sendCode = async () => { if (!quickValidateEmail(normalizeEmail(email)).isValid) { - setError('Please enter a valid email address.') + setError({ kind: 'request', message: 'Please enter a valid email address.' }) return } setError(null) @@ -48,7 +52,10 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { setSent(true) setOtp('') } catch (err) { - setError(getErrorMessage(err, 'Failed to send verification code')) + setError({ + kind: 'request', + message: getErrorMessage(err, 'Failed to send verification code'), + }) } } @@ -59,7 +66,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { await verifyOtp.mutateAsync({ email: normalizeEmail(email), otp: code }) router.refresh() } catch (err) { - setError(getErrorMessage(err, 'Invalid verification code')) + setError({ kind: 'verification', message: getErrorMessage(err, 'Invalid verification code') }) } } @@ -71,7 +78,10 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { setError(null) } catch (err) { setCountdown(0) - setError(getErrorMessage(err, 'Failed to resend verification code')) + setError({ + kind: 'request', + message: getErrorMessage(err, 'Failed to resend verification code'), + }) } } @@ -107,7 +117,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { size='lg' error={Boolean(error)} /> - {error ?

{error}

: null} + {error ?

{error.message}

: null}
{[0, 1, 2, 3, 4, 5].map((i) => ( - + ))}
- {error ?

{error}

: null} + {error ? ( +

{error.message}

+ ) : null} { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.unstubAllGlobals() +}) + +function renderOtp(invalid: boolean) { + act(() => { + root.render( + + + + + + + ) + }) +} + +describe('InputOTPSlot invalid state', () => { + it('keeps the standard border when valid', () => { + renderOtp(false) + const slot = host.querySelector('[data-testid="first-slot"]') + expect(slot?.classList.contains('border-[var(--border-1)]')).toBe(true) + expect(slot?.classList.contains('border-[var(--text-error)]')).toBe(false) + expect(host.querySelector('input')?.getAttribute('aria-invalid')).toBe('false') + }) + + it('keeps the error border while the active slot has a focus ring', () => { + renderOtp(true) + const input = host.querySelector('input') + expect(input?.getAttribute('aria-invalid')).toBe('true') + + act(() => input?.focus()) + const slot = host.querySelector('[data-testid="first-slot"]') + expect(slot?.classList.contains('border-[var(--text-error)]')).toBe(true) + expect(slot?.classList.contains('ring-1')).toBe(true) + expect(slot?.classList.contains('border-[var(--text-muted)]')).toBe(false) + }) +}) diff --git a/packages/emcn/src/components/input-otp/input-otp.tsx b/packages/emcn/src/components/input-otp/input-otp.tsx index a6c5ea82fff..2c3ff3a83b7 100644 --- a/packages/emcn/src/components/input-otp/input-otp.tsx +++ b/packages/emcn/src/components/input-otp/input-otp.tsx @@ -66,32 +66,39 @@ InputOTPGroup.displayName = 'InputOTPGroup' * * Uses emcn design tokens for consistent styling with the Input component. */ -const InputOTPSlot = React.forwardRef< - React.ElementRef<'div'>, - React.ComponentPropsWithoutRef<'div'> & { index: number } ->(({ index, className, ...props }, ref) => { - const inputOTPContext = React.useContext(OTPInputContext) - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] +export interface InputOTPSlotProps extends React.ComponentPropsWithoutRef<'div'> { + /** Zero-based position of this slot in the OTP input. */ + index: number + /** Keep the error border visible even while this slot is active. */ + invalid?: boolean +} + +const InputOTPSlot = React.forwardRef, InputOTPSlotProps>( + ({ index, invalid = false, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] - return ( -
- {char} - {hasFakeCaret && ( -
-
-
- )} -
- ) -}) + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) + } +) InputOTPSlot.displayName = 'InputOTPSlot' /**