diff --git a/src/components/ui/form/FormControl.tsx b/src/components/ui/form/FormControl.tsx index 92f31e466..8ce80f069 100644 --- a/src/components/ui/form/FormControl.tsx +++ b/src/components/ui/form/FormControl.tsx @@ -2,14 +2,14 @@ import { useFormField } from '@/components/ui/form/useFormField'; import { Slot } from '@radix-ui/react-slot'; import { ComponentProps } from 'react'; -export function FormControl({ ...props }: ComponentProps) { +export function FormControl({ 'aria-describedby': describedBy, ...props }: ComponentProps) { const { error, formItemId, formDescriptionId, formMessageId } = useFormField(); return ( diff --git a/src/components/ui/form/LengthLimitedInput.test.tsx b/src/components/ui/form/LengthLimitedInput.test.tsx new file mode 100644 index 000000000..4e9736bfd --- /dev/null +++ b/src/components/ui/form/LengthLimitedInput.test.tsx @@ -0,0 +1,177 @@ +/** + * @vitest-environment jsdom + */ +import { Form } from '@/components/ui/form/Form'; +import { FormField } from '@/components/ui/form/FormField'; +import { FormItem } from '@/components/ui/form/FormItem'; +import { FormLabel } from '@/components/ui/form/FormLabel'; +import { FormMessage } from '@/components/ui/form/FormMessage'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { afterEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { LengthLimitedInput } from './LengthLimitedInput'; + +function NameForm({ type = 'text', name = '', maxLength = 10, focus = false }: { + type?: 'text' | 'email'; + name?: string; + maxLength?: number; + focus?: boolean; +}) { + const form = useForm({ defaultValues: { name } }); + const { setFocus } = form; + useEffect(() => { + if (focus) { + setFocus('name'); + } + }, [focus, setFocus]); + return ( +
+ ( + + Name + + + + )} + /> + + ); +} + +afterEach(cleanup); + +const input = () => screen.getByLabelText('Name') as HTMLInputElement; +const hint = () => document.querySelector('[data-slot="form-length-hint"]')!; +const type = (value: string) => fireEvent.change(input(), { target: { value } }); +const description = () => + (input().getAttribute('aria-describedby') ?? '').split(' ').map(id => document.getElementById(id)?.textContent) + .join(' ').trim(); + +// jsdom has no paste default action, so `landed` stands in for what the browser inserts. +function startPaste(text: string, landed: string) { + fireEvent.paste(input(), { clipboardData: { getData: () => text } }); + type(landed); +} +async function paste(text: string, landed: string) { + startPaste(text, landed); + await act(() => new Promise(resolve => setTimeout(resolve))); +} + +describe('LengthLimitedInput', () => { + it('stops the input at the limit and says so, politely, in its accessible description', () => { + render(); + expect(input().maxLength).toBe(10); + + type('a'.repeat(10)); + + expect(hint().textContent).toBe('Maximum 10 characters reached.'); + expect(description()).toBe('Maximum 10 characters reached.'); + expect(hint().getAttribute('aria-live')).toBe('polite'); + }); + + it("passes react-hook-form the input's ref, so a refused field can still be focused", async () => { + render(); + + await waitFor(() => expect(document.activeElement).toBe(input())); + }); + + it('says nothing below the limit, from a live region that is already in place', () => { + render(); + type('a'.repeat(9)); + + expect(hint().textContent).toBe(''); + expect(hint().getAttribute('aria-live')).toBe('polite'); + expect(input().getAttribute('aria-describedby')).toContain(hint().id); + }); + + it('clears once the value is back under the limit', () => { + render(); + type('a'.repeat(10)); + type('a'.repeat(9)); + + expect(hint().textContent).toBe(''); + }); + + it('says when a paste was cut, how long it was and how much of it fit', async () => { + render(); + await paste('p'.repeat(312), 'p'.repeat(10)); + + expect(description()).toBe( + 'Maximum 10 characters reached. Pasted text was 312 characters; only the first 10 fit.', + ); + }); + + it('counts only the room left beside what the field already held', async () => { + render(); + type('abcd'); + await paste('x'.repeat(20), `abcd${'x'.repeat(6)}`); + + expect(hint().textContent).toBe( + 'Maximum 10 characters reached. Pasted text was 20 characters; only the first 6 fit.', + ); + }); + + it('announces the cut paste alone, not the limit first and then the paste', async () => { + render(); + startPaste('p'.repeat(312), 'p'.repeat(10)); + + expect(hint().textContent).toBe(''); + await act(() => new Promise(resolve => setTimeout(resolve))); + expect(hint().textContent).toContain('Pasted text was 312 characters'); + }); + + it('keeps the paste notice while the value is what the paste left, and drops it on any change', async () => { + render(); + await paste('p'.repeat(312), 'p'.repeat(10)); + expect(hint().textContent).toContain('Pasted text was 312 characters'); + + type(`${'p'.repeat(9)}q`); + expect(hint().textContent).toBe('Maximum 10 characters reached.'); + + type('p'.repeat(9)); + expect(hint().textContent).toBe(''); + }); + + it('says none of a paste fit when the browser stopped short of a surrogate pair', async () => { + render(); + type('a'.repeat(9)); + await paste('πŸ˜€', 'a'.repeat(9)); + + expect(hint().textContent).toBe('Maximum 10 characters. Pasted text was 2 characters; none of it fit.'); + }); + + it('says nothing about a paste that fit', async () => { + render(); + await paste('abc', 'abc'); + + expect(hint().textContent).toBe(''); + }); + + it('says how far over the limit a value starts, such as a name saved before the limit was lowered', () => { + render(); + + expect(description()).toBe('Maximum 10 characters; this has 12.'); + }); + + it('leaves the field alone when its schema sets no maximum, where zod reports the limit as null', () => { + render(); + type('a'.repeat(50)); + + expect(input().maxLength).toBe(-1); + expect(document.querySelector('[data-slot="form-length-hint"]')).toBeNull(); + }); + + it('makes no claim about a paste into a field that hides its selection, as type="email" does', async () => { + render(); + expect(input().selectionStart).toBeNull(); + type('a@b.co'); + await paste(`bob@${'e'.repeat(40)}.com`, `bob@${'e'.repeat(6)}`); + + expect(hint().textContent).toBe('Maximum 10 characters reached.'); + }); +}); diff --git a/src/components/ui/form/LengthLimitedInput.tsx b/src/components/ui/form/LengthLimitedInput.tsx new file mode 100644 index 000000000..221c8cd62 --- /dev/null +++ b/src/components/ui/form/LengthLimitedInput.tsx @@ -0,0 +1,19 @@ +import { FormControl } from '@/components/ui/form/FormControl'; +import { useLengthLimit } from '@/components/ui/form/useLengthLimit'; +import { Input } from '@/components/ui/input'; +import { ComponentProps } from 'react'; + +export function LengthLimitedInput( + { maxLength, onPaste, ...props }: ComponentProps & { maxLength: number }, +) { + const { controlProps, inputProps, hint } = useLengthLimit(maxLength, onPaste); + + return ( + <> + + + + {hint} + + ); +} diff --git a/src/components/ui/form/measurePasteCut.test.ts b/src/components/ui/form/measurePasteCut.test.ts new file mode 100644 index 000000000..737de3098 --- /dev/null +++ b/src/components/ui/form/measurePasteCut.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { measurePasteCut } from './measurePasteCut'; + +const long = 'x'.repeat(312); + +describe('measurePasteCut', () => { + it('reports what an empty field kept of a longer paste', () => { + expect(measurePasteCut({ text: long, before: '', after: long.slice(0, 80), selection: [0, 0], maxLength: 80 })) + .toEqual({ pasted: 312, kept: 80 }); + }); + + it('counts only the room left beside what the field already held', () => { + const before = 'a'.repeat(10); + const after = before + 'b'.repeat(70); + expect(measurePasteCut({ text: 'b'.repeat(75), before, after, selection: [10, 10], maxLength: 80 })) + .toEqual({ pasted: 75, kept: 70 }); + }); + + it('counts the selection the paste replaces as room', () => { + const before = 'a'.repeat(50); + const text = 'b'.repeat(80); + expect(measurePasteCut({ text, before, after: text, selection: [0, 50], maxLength: 80 })).toBeNull(); + }); + + it('says none of it fit when the field was already full', () => { + const full = 'a'.repeat(80); + expect(measurePasteCut({ text: 'hello', before: full, after: full, selection: [80, 80], maxLength: 80 })) + .toEqual({ pasted: 5, kept: 0 }); + }); + + it('counts what landed when the browser stopped short of a surrogate pair', () => { + const before = 'a'.repeat(79); + expect(measurePasteCut({ text: 'πŸ˜€', before, after: before, selection: [79, 79], maxLength: 80 })) + .toEqual({ pasted: 2, kept: 0 }); + }); + + it('ignores a paste that fit exactly', () => { + const text = 'b'.repeat(80); + expect(measurePasteCut({ text, before: '', after: text, selection: [0, 0], maxLength: 80 })).toBeNull(); + }); + + it('ignores the trailing line break a copied line carries, and counts each inner break as one space', () => { + const text = `${'b'.repeat(39)}\r\n${'c'.repeat(40)}\n`; + const after = `${'b'.repeat(39)} ${'c'.repeat(40)}`; + expect(measurePasteCut({ text, before: '', after, selection: [0, 0], maxLength: 80 })).toBeNull(); + }); + + it('ignores a paste that left the field short of its limit, which something other than the limit refused', () => { + expect(measurePasteCut({ text: long, before: 'abc', after: 'abc', selection: [3, 3], maxLength: 80 })).toBeNull(); + }); +}); diff --git a/src/components/ui/form/measurePasteCut.ts b/src/components/ui/form/measurePasteCut.ts new file mode 100644 index 000000000..dcb254025 --- /dev/null +++ b/src/components/ui/form/measurePasteCut.ts @@ -0,0 +1,25 @@ +export type PasteCut = { pasted: number; kept: number }; + +type PasteMeasurement = { + text: string; + before: string; + after: string; + selection: readonly [number, number]; + maxLength: number; +}; + +export function measurePasteCut({ text, before, after, selection, maxLength }: PasteMeasurement): PasteCut | null { + // What a single-line input inserts: trailing line breaks dropped, the rest turned into spaces. + const inserted = text.replace(/[\r\n]+$/, '').replace(/\r\n|\r|\n/g, ' '); + // The browser cuts only by filling the field, though it may stop one short rather than split a surrogate pair. + if (!inserted || after.length < maxLength - 1) { + return null; + } + const outside = before.length - (selection[1] - selection[0]); + const room = Math.max(0, maxLength - outside); + if (room >= inserted.length) { + return null; + } + // What landed, which is short of the room when the browser stopped before a surrogate pair. + return { pasted: inserted.length, kept: Math.min(room, Math.max(0, after.length - outside)) }; +} diff --git a/src/components/ui/form/useFormField.tsx b/src/components/ui/form/useFormField.tsx index 9deff108f..9ee39a020 100644 --- a/src/components/ui/form/useFormField.tsx +++ b/src/components/ui/form/useFormField.tsx @@ -23,6 +23,7 @@ export function useFormField() { formItemId: `${id}-form-item`, formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, + formLengthHintId: `${id}-form-item-length-hint`, error, }; } diff --git a/src/components/ui/form/useLengthLimit.tsx b/src/components/ui/form/useLengthLimit.tsx new file mode 100644 index 000000000..8d4884431 --- /dev/null +++ b/src/components/ui/form/useLengthLimit.tsx @@ -0,0 +1,96 @@ +import { measurePasteCut, PasteCut } from '@/components/ui/form/measurePasteCut'; +import { useFormField } from '@/components/ui/form/useFormField'; +import { pluralize } from '@/lib/pluralize'; +import { TriangleAlertIcon } from 'lucide-react'; +import { ClipboardEvent, ClipboardEventHandler, useEffect, useRef, useState } from 'react'; +import { useWatch } from 'react-hook-form'; + +type PasteState = 'pending' | (PasteCut & { value: string }) | null; + +/** The browser cuts a paste to `maxLength` without a word. */ +export function useLengthLimit( + maxLength: number | null | undefined, + onPaste?: ClipboardEventHandler, +) { + const { name, formLengthHintId } = useFormField(); + const watched: unknown = useWatch({ name, disabled: maxLength == null }); + const value = typeof watched === 'string' ? watched : ''; + const [paste, setPaste] = useState(null); + const timer = useRef>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + + // zod's `.maxLength` is null on a schema without `.max()`. + if (maxLength == null) { + return { controlProps: {}, inputProps: { onPaste }, hint: null }; + } + + // Any later change, even one that stays at the limit, means the value is no longer what the paste left. + let shown = paste; + if (shown && shown !== 'pending' && value !== shown.value) { + shown = null; + setPaste(null); + } + + const handlePaste = (event: ClipboardEvent) => { + onPaste?.(event); + const input = event.currentTarget; + const text = event.clipboardData?.getData('text/plain') ?? ''; + const { selectionStart: start, selectionEnd: end } = input; + // `type="email"` hides its selection, so there is no cut to measure. Email fields are left uncapped + // anyway, since a cut address can still be a valid one, just not the user's. + if (event.defaultPrevented || input.readOnly || !text || start === null || end === null) { + return; + } + const before = input.value; + const selection = [start, end] as const; + setPaste('pending'); + clearTimeout(timer.current); + // The browser inserts the paste after this handler returns, so measure once it has. + timer.current = setTimeout(() => { + const after = input.value; + const cut = measurePasteCut({ text, before, after, selection, maxLength }); + setPaste(cut && { ...cut, value: after }); + }); + }; + + const limit = describeLimit(maxLength, value.length); + let message: string | null = null; + if (shown && shown !== 'pending') { + const fit = shown.kept ? `only the first ${shown.kept} fit` : 'none of it fit'; + message = `${limit} Pasted text was ${pluralize(shown.pasted, 'character', 'characters')}; ${fit}.`; + } else if (!shown && value.length >= maxLength) { + // Not while a paste is pending, or the limit is announced and then the paste right after it. + message = limit; + } + + return { + controlProps: { 'aria-describedby': formLengthHintId }, + inputProps: { maxLength, onPaste: handlePaste }, + hint: , + }; +} + +function describeLimit(maxLength: number, length: number) { + // A value can start over the limit, such as a name saved before the limit was lowered. + if (length > maxLength) { + return `Maximum ${maxLength} characters; this has ${length}.`; + } + // Short of it only after a paste that stopped before a surrogate pair. + return length === maxLength ? `Maximum ${maxLength} characters reached.` : `Maximum ${maxLength} characters.`; +} + +// Always rendered, so screen readers already track the live region when the message arrives. +function FormLengthHint({ id, message }: { id: string; message: string | null }) { + return ( +

+ {message &&

+ ); +} diff --git a/src/features/auth/AuthLayout.css b/src/features/auth/AuthLayout.css index 43d410b9e..64282a862 100644 --- a/src/features/auth/AuthLayout.css +++ b/src/features/auth/AuthLayout.css @@ -542,7 +542,7 @@ padding-inline: 0; } -.auth-form:not(.fabric-sign-in) p:not([role="alert"]):not([data-slot="form-message"]) { +.auth-form:not(.fabric-sign-in) p:not([role="alert"]):not([data-slot="form-message"]):not([data-slot="form-length-hint"]) { color: var(--signin-muted); line-height: 1.6; } diff --git a/src/features/auth/ResetPassword.test.tsx b/src/features/auth/ResetPassword.test.tsx new file mode 100644 index 000000000..4ddadc243 --- /dev/null +++ b/src/features/auth/ResetPassword.test.tsx @@ -0,0 +1,67 @@ +/** + * @vitest-environment jsdom + */ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), + useSearch: () => ({ token: 'reset-token' }), +})); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), dismiss: vi.fn() } })); +const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() })); +vi.mock('./hooks/useResetPassword', () => ({ useResetPasswordMutation: () => ({ mutate, isPending: false }) })); + +import { ResetPassword } from './ResetPassword'; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function submitNewPassword(value: string) { + await act(async () => { + fireEvent.change(screen.getByLabelText('New Password'), { target: { value } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value } }); + fireEvent.click(screen.getByRole('button', { name: 'Submit New Password' })); + }); +} + +describe('ResetPassword', () => { + // maxlength would silently clamp a pasted password, leaving the password manager holding one that never worked. + it('lets an over-long password be typed in full and explains the limit instead', async () => { + render(); + const password = screen.getByLabelText('New Password') as HTMLInputElement; + const confirmation = screen.getByLabelText('Confirm Password') as HTMLInputElement; + expect(password.maxLength).toBe(-1); + expect(confirmation.maxLength).toBe(-1); + + await submitNewPassword('p'.repeat(257)); + + expect(password.value).toHaveLength(257); + expect(await screen.findByText('Password cannot be longer than 256 characters.')).toBeTruthy(); + expect(mutate).not.toHaveBeenCalled(); + }); + + it.each([51, 256])('accepts a %i-character password, as sign-up does', async (length) => { + render(); + const value = 'p'.repeat(length); + + await submitNewPassword(value); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + expect(mutate.mock.calls[0][0]).toEqual({ token: 'reset-token', password: value }); + expect(screen.queryByText(/cannot be longer than/)).toBeNull(); + }); +}); + +describe('ResetPassword password fields', () => { + it('never carry a length hint, however long the password runs', () => { + render(); + fireEvent.change(screen.getByLabelText('New Password'), { target: { value: 'p'.repeat(300) } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'p'.repeat(300) } }); + + expect(document.querySelector('[data-slot="form-length-hint"]')).toBeNull(); + expect(screen.queryByText(/^Maximum/)).toBeNull(); + }); +}); diff --git a/src/features/auth/ResetPassword.tsx b/src/features/auth/ResetPassword.tsx index 36bfa3bcc..792cdbedd 100644 --- a/src/features/auth/ResetPassword.tsx +++ b/src/features/auth/ResetPassword.tsx @@ -4,7 +4,7 @@ import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; -import { zodRequirePassword } from '@/lib/zod/password'; +import { ACCOUNT_PASSWORD_MAX_LENGTH, ACCOUNT_PASSWORD_TOO_LONG_ERROR, zodRequirePassword } from '@/lib/zod/password'; import { zodResolver } from '@hookform/resolvers/zod'; import { useNavigate, useSearch } from '@tanstack/react-router'; import { useCallback, useEffect } from 'react'; @@ -18,7 +18,7 @@ const ResetPasswordSchema = z .object({ password: zodRequirePassword .min(8, { error: 'Password must be at least 8 characters long.' }) - .max(50, { error: 'Password cannot be longer than 50 characters.' }), + .max(ACCOUNT_PASSWORD_MAX_LENGTH, { error: ACCOUNT_PASSWORD_TOO_LONG_ERROR }), confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { diff --git a/src/features/auth/SignUp.test.tsx b/src/features/auth/SignUp.test.tsx index a5519625c..d61e53874 100644 --- a/src/features/auth/SignUp.test.tsx +++ b/src/features/auth/SignUp.test.tsx @@ -98,6 +98,40 @@ beforeEach(() => { afterEach(() => vi.clearAllMocks()); describe('SignUp', () => { + it('stops names at their limits but lets an email or a password run long', () => { + renderSignUp(); + const maxLengthOf = (label: string) => (screen.getByLabelText(label) as HTMLInputElement).maxLength; + + expect(maxLengthOf('First Name')).toBe(40); + expect(maxLengthOf('Last Name')).toBe(80); + expect(maxLengthOf('Email')).toBe(-1); + expect(maxLengthOf('Password')).toBe(-1); + expect(maxLengthOf('Confirm Password')).toBe(-1); + }); + + it('refuses a password longer than central manager accepts before sending it', async () => { + renderSignUp(); + fillValidForm(); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'p'.repeat(257) } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'p'.repeat(257) } }); + submit(); + + expect(await screen.findByText('Password cannot be longer than 256 characters.')).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); + }); + + it('sends a password right at central manager’s limit', async () => { + post.mockResolvedValue({ data: { id: 'usr-1', email: 'taken@example.com' } }); + renderSignUp(); + fillValidForm(); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'p'.repeat(256) } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'p'.repeat(256) } }); + submit(); + + await waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + expect(post.mock.calls[0][1]).toMatchObject({ password: 'p'.repeat(256) }); + }); + it('reveals each password independently without submitting or losing field values', () => { renderSignUp(); const password = screen.getByLabelText('Password') as HTMLInputElement; @@ -313,6 +347,46 @@ describe('SignUp', () => { settle!(); await waitFor(() => expect(button.hasAttribute('disabled')).toBe(false)); }); + + it('says when a name reaches its limit, and when a paste was cut, but never on the email or a password', async () => { + renderSignUp(); + const descriptionOf = (label: string) => + screen.getByLabelText(label).getAttribute('aria-describedby')!.split(' ') + .map(id => document.getElementById(id)?.textContent).join(' ').trim(); + + fireEvent.change(screen.getByLabelText('First Name'), { target: { value: 'A'.repeat(39) } }); + fireEvent.paste(screen.getByLabelText('Last Name'), { clipboardData: { getData: () => 'l'.repeat(312) } }); + fireEvent.change(screen.getByLabelText('Last Name'), { target: { value: 'l'.repeat(80) } }); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: `${'e'.repeat(300)}@example.com` } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'p'.repeat(300) } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'p'.repeat(300) } }); + + expect(descriptionOf('First Name')).toBe(''); + fireEvent.change(screen.getByLabelText('First Name'), { target: { value: 'A'.repeat(40) } }); + expect(descriptionOf('First Name')).toBe('Maximum 40 characters reached.'); + await waitFor(() => + expect(descriptionOf('Last Name')).toBe( + 'Maximum 80 characters reached. Pasted text was 312 characters; only the first 80 fit.', + ) + ); + expect(descriptionOf('Email')).toBe(''); + expect(descriptionOf('Password')).toBe(''); + expect(descriptionOf('Confirm Password')).toBe(''); + expect(document.querySelectorAll('[data-slot="form-length-hint"]')).toHaveLength(2); + }); + + it('lets an over-long email be typed in full and refuses it before sending, as it does a password', async () => { + renderSignUp(); + fillValidForm(); + const email = screen.getByLabelText('Email') as HTMLInputElement; + const address = `${'u'.repeat(69)}@example.com`; + fireEvent.change(email, { target: { value: address } }); + submit(); + + expect(email.value).toHaveLength(81); + expect(await screen.findByText('Email cannot be longer than 80 characters.')).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); + }); }); describe('SignUp β€” reCAPTCHA', () => { diff --git a/src/features/auth/SignUp.tsx b/src/features/auth/SignUp.tsx index 8e1dd30d1..f3d59a32a 100644 --- a/src/features/auth/SignUp.tsx +++ b/src/features/auth/SignUp.tsx @@ -11,7 +11,7 @@ import { parseCompanyFromEmail } from '@/lib/string/parseCompanyFromEmail'; import { personNameRegex } from '@/lib/string/regex/personNameRegex'; import { clearUtmParamsFromUrl } from '@/lib/urls/clearUtmParams'; import { zodRequireEmail } from '@/lib/zod/email'; -import { zodRequirePassword } from '@/lib/zod/password'; +import { ACCOUNT_PASSWORD_MAX_LENGTH, ACCOUNT_PASSWORD_TOO_LONG_ERROR, zodRequirePassword } from '@/lib/zod/password'; import { zodResolver } from '@hookform/resolvers/zod'; import { Link, useNavigate, useSearch } from '@tanstack/react-router'; import { MouseEvent, useCallback, useEffect, useState } from 'react'; @@ -53,7 +53,8 @@ const SignUpSchema = z.object({ ) .max(80, { error: 'Last name cannot be longer than 80 characters.' }), password: zodRequirePassword - .min(8, { error: 'Password must be at least 8 characters long.' }), + .min(8, { error: 'Password must be at least 8 characters long.' }) + .max(ACCOUNT_PASSWORD_MAX_LENGTH, { error: ACCOUNT_PASSWORD_TOO_LONG_ERROR }), confirmPassword: z.string(), acceptTerms: z.boolean().refine(val => val === true, { message: 'You must accept the Privacy Policy and Terms of Service.', @@ -222,6 +223,7 @@ export function SignUp() { , 'type'> & { passwordLabel?: string; }; -export function AuthInput({ type = 'text', passwordLabel = 'password', ...props }: AuthInputProps) { +export function AuthInput({ type = 'text', passwordLabel = 'password', maxLength, onPaste, ...props }: AuthInputProps) { const [visible, setVisible] = useState(false); + const limit = useLengthLimit(maxLength, onPaste); const Icon = type === 'email' ? Mail : type === 'password' ? LockKeyhole : UserRound; return ( -
- - - -
+ <> +
+ + + +
+ {limit.hint} + ); } diff --git a/src/features/clusters/upsert/fields/ClusterAbbreviatedName.tsx b/src/features/clusters/upsert/fields/ClusterAbbreviatedName.tsx index c0e37a52b..e5d6f334b 100644 --- a/src/features/clusters/upsert/fields/ClusterAbbreviatedName.tsx +++ b/src/features/clusters/upsert/fields/ClusterAbbreviatedName.tsx @@ -1,10 +1,9 @@ import { HostnamePreview } from '@/components/HostnamePreview'; -import { FormControl } from '@/components/ui/form/FormControl'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; -import { Input } from '@/components/ui/input'; +import { LengthLimitedInput } from '@/components/ui/form/LengthLimitedInput'; import { specifiedAbbreviatedName, UpsertClusterSchema } from '@/features/clusters/upsert/upsertClusterSchema'; import { useId } from 'react'; import { UseFormReturn } from 'react-hook-form'; @@ -28,18 +27,16 @@ export function ClusterAbbreviatedName({ render={({ field }) => ( Host Name - - - + )} diff --git a/src/features/clusters/upsert/fields/ClusterFQDN.tsx b/src/features/clusters/upsert/fields/ClusterFQDN.tsx index b5c2a0550..b4fc027d5 100644 --- a/src/features/clusters/upsert/fields/ClusterFQDN.tsx +++ b/src/features/clusters/upsert/fields/ClusterFQDN.tsx @@ -29,7 +29,7 @@ export function ClusterFQDN({ autoCapitalize="none" autoComplete="off" autoCorrect="off" - placeholder="example.your-company.com" + placeholder="e.g. cluster.your-company.com" disabled={disabled} /> diff --git a/src/features/clusters/upsert/fields/ClusterName.tsx b/src/features/clusters/upsert/fields/ClusterName.tsx index 51ff132db..1eae7785e 100644 --- a/src/features/clusters/upsert/fields/ClusterName.tsx +++ b/src/features/clusters/upsert/fields/ClusterName.tsx @@ -1,9 +1,8 @@ -import { FormControl } from '@/components/ui/form/FormControl'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; -import { Input } from '@/components/ui/input'; +import { LengthLimitedInput } from '@/components/ui/form/LengthLimitedInput'; import { UpsertClusterSchema } from '@/features/clusters/upsert/upsertClusterSchema'; import { UseFormReturn } from 'react-hook-form'; import { z } from 'zod'; @@ -24,16 +23,14 @@ export function ClusterName({ render={({ field }) => ( Cluster Name - - - + )} diff --git a/src/features/clusters/upsert/fields/ClusterNameFields.test.tsx b/src/features/clusters/upsert/fields/ClusterNameFields.test.tsx new file mode 100644 index 000000000..7e1c69022 --- /dev/null +++ b/src/features/clusters/upsert/fields/ClusterNameFields.test.tsx @@ -0,0 +1,50 @@ +/** + * @vitest-environment jsdom + */ +import { Form } from '@/components/ui/form/Form'; +import { UpsertClusterSchema } from '@/features/clusters/upsert/upsertClusterSchema'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { useForm } from 'react-hook-form'; +import { afterEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { ClusterAbbreviatedName } from './ClusterAbbreviatedName'; +import { ClusterName } from './ClusterName'; + +function ClusterNameFields() { + const form = useForm>({ + defaultValues: { clusterName: '', abbreviatedName: '' }, + }); + return ( +
+ + + + ); +} + +afterEach(cleanup); + +describe('ClusterName', () => { + it('stops the cluster name at the 255 characters the form accepts, and says so there', () => { + render(); + const name = screen.getByLabelText('Cluster Name') as HTMLInputElement; + expect(name.maxLength).toBe(255); + + fireEvent.change(name, { target: { value: 'c'.repeat(255) } }); + expect(screen.getByText('Maximum 255 characters reached.')).toBeTruthy(); + }); +}); + +describe('ClusterAbbreviatedName', () => { + it('stops the host name at the 20 characters the form accepts, and says so there', () => { + render(); + const hostName = screen.getByLabelText('Host Name') as HTMLInputElement; + expect(hostName.maxLength).toBe(20); + + fireEvent.change(hostName, { target: { value: 'h'.repeat(20) } }); + expect(screen.getByText('Maximum 20 characters reached.')).toBeTruthy(); + }); +}); diff --git a/src/features/instance/apis/explorer/SettingsPanel.tsx b/src/features/instance/apis/explorer/SettingsPanel.tsx index 1b08c5690..8178bfb05 100644 --- a/src/features/instance/apis/explorer/SettingsPanel.tsx +++ b/src/features/instance/apis/explorer/SettingsPanel.tsx @@ -356,7 +356,12 @@ function BearerForm({ auth, onApply }: { auth: ApiAuth; onApply: (token: string) }} > - setToken(e.target.value)} /> + setToken(e.target.value)} + /> ); diff --git a/src/features/instance/applications/components/NewApplication/CommonInstructions.tsx b/src/features/instance/applications/components/NewApplication/CommonInstructions.tsx index a6acde1ec..54dca3cc7 100644 --- a/src/features/instance/applications/components/NewApplication/CommonInstructions.tsx +++ b/src/features/instance/applications/components/NewApplication/CommonInstructions.tsx @@ -1,10 +1,9 @@ import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { FormControl } from '@/components/ui/form/FormControl'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; -import { Input } from '@/components/ui/input'; +import { LengthLimitedInput } from '@/components/ui/form/LengthLimitedInput'; import { Control } from 'react-hook-form'; import { z } from 'zod'; import { NewApplicationSchema } from './schema'; @@ -32,16 +31,15 @@ export function CommonInstructions({ render={({ field }) => ( Name - - - + )} diff --git a/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx b/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx index 168e481ee..a1786eef0 100644 --- a/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx +++ b/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx @@ -151,7 +151,7 @@ export function ImportInstructions({ type="text" autoCapitalize="none" autoComplete="off" - placeholder="npm install" + placeholder="Leave blank to install dependencies automatically" {...field} /> diff --git a/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx b/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx index 3078936a6..d5a9c7d74 100644 --- a/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx +++ b/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx @@ -98,6 +98,15 @@ describe('NewApplication', () => { expect(screen.queryByText('Please enter a URL or package reference.')).toBeNull(); }); + it('stops the application name at the 75 characters the form accepts, and says so there', () => { + render(); + const name = screen.getByLabelText('Name') as HTMLInputElement; + expect(name.maxLength).toBe(75); + + fireEvent.change(name, { target: { value: 'a'.repeat(75) } }); + expect(screen.getByText('Maximum 75 characters reached.')).toBeTruthy(); + }); + it('explains a refused application name when Enter is pressed in it', async () => { render(); await act(async () => { diff --git a/src/features/instance/applications/components/SchemaEditorView/FieldRow.tsx b/src/features/instance/applications/components/SchemaEditorView/FieldRow.tsx index b9302bcac..8b810e65d 100644 --- a/src/features/instance/applications/components/SchemaEditorView/FieldRow.tsx +++ b/src/features/instance/applications/components/SchemaEditorView/FieldRow.tsx @@ -217,7 +217,7 @@ export function FieldRow({
setStr('indexed', 'type', value)} diff --git a/src/features/instance/applications/modals/AddDirectoryOrFileModal.test.tsx b/src/features/instance/applications/modals/AddDirectoryOrFileModal.test.tsx new file mode 100644 index 000000000..3fc768ff7 --- /dev/null +++ b/src/features/instance/applications/modals/AddDirectoryOrFileModal.test.tsx @@ -0,0 +1,53 @@ +/** + * @vitest-environment jsdom + */ +import type { FileEntry } from '@/features/instance/applications/context/fileEntry'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +const openedEntry: FileEntry = { name: 'index.js', path: 'my-app/index.js', project: 'my-app' } as FileEntry; + +vi.mock('@/config/useInstanceClient', () => ({ + useInstanceClientIdParams: () => ({ entityId: 'test-instance', entityType: 'instance' }), +})); +vi.mock('@/features/instance/applications/hooks/useEditorView', () => ({ + useEditorView: () => ({ + openedEntry, + entryExists: () => false, + reloadRootEntries: vi.fn(), + setFocusedItem: vi.fn(), + setSelectedItems: vi.fn(), + setExpandedItems: vi.fn(), + }), +})); +vi.mock('@/integrations/api/instance/applications/setComponentFile', () => ({ + useSetComponentFile: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock('@/lib/events/watcher', () => ({ + useWatchedValue: () => ({ value: 'file', trigger: undefined }), + setWatchedValue: vi.fn(), +})); +vi.mock('@/lib/attemptToRestoreFocus', () => ({ attemptToRestoreFocus: vi.fn() })); + +import { AddDirectoryOrFileModal } from './AddDirectoryOrFileModal'; + +beforeAll(() => { + // Radix Dialog relies on DOM APIs jsdom doesn't implement. + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.setPointerCapture ??= () => undefined; + Element.prototype.releasePointerCapture ??= () => undefined; + Element.prototype.scrollIntoView ??= () => undefined; +}); + +afterEach(cleanup); + +describe('AddDirectoryOrFileModal', () => { + it('stops the name at the 50 characters the form accepts, and says so there', () => { + render(); + const name = screen.getByLabelText('Name') as HTMLInputElement; + expect(name.maxLength).toBe(50); + + fireEvent.change(name, { target: { value: 'a'.repeat(50) } }); + expect(screen.getByText('Maximum 50 characters reached.')).toBeTruthy(); + }); +}); diff --git a/src/features/instance/applications/modals/AddDirectoryOrFileModal.tsx b/src/features/instance/applications/modals/AddDirectoryOrFileModal.tsx index 84f4ce94e..9b6692627 100644 --- a/src/features/instance/applications/modals/AddDirectoryOrFileModal.tsx +++ b/src/features/instance/applications/modals/AddDirectoryOrFileModal.tsx @@ -8,12 +8,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Form } from '@/components/ui/form/Form'; -import { FormControl } from '@/components/ui/form/FormControl'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; -import { Input } from '@/components/ui/input'; +import { LengthLimitedInput } from '@/components/ui/form/LengthLimitedInput'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { isDirectory } from '@/features/instance/applications/context/isDirectory'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; @@ -165,15 +164,14 @@ export function AddDirectoryOrFileModal() { render={({ field }) => ( Name - - - + )} diff --git a/src/features/instance/applications/modals/RedeployApplicationModal.tsx b/src/features/instance/applications/modals/RedeployApplicationModal.tsx index 1e907e3e3..ae8cd5e6a 100644 --- a/src/features/instance/applications/modals/RedeployApplicationModal.tsx +++ b/src/features/instance/applications/modals/RedeployApplicationModal.tsx @@ -307,7 +307,7 @@ export function RedeployApplicationModal() { diff --git a/src/features/instance/applications/modals/RenameFileModal.test.tsx b/src/features/instance/applications/modals/RenameFileModal.test.tsx index d3404ab1a..3113fe4b9 100644 --- a/src/features/instance/applications/modals/RenameFileModal.test.tsx +++ b/src/features/instance/applications/modals/RenameFileModal.test.tsx @@ -52,6 +52,15 @@ function renameButton() { } describe('RenameFileModal', () => { + it('stops the name at the 50 characters the form accepts, and says so there', () => { + render(); + const name = screen.getByLabelText('Name') as HTMLInputElement; + expect(name.maxLength).toBe(50); + + fireEvent.change(name, { target: { value: 'a'.repeat(50) } }); + expect(screen.getByText('Maximum 50 characters reached.')).toBeTruthy(); + }); + it('shows the reason when Enter is pressed, then clears it once the name is fixed', async () => { render(); await typeName('index(1).js'); diff --git a/src/features/instance/applications/modals/RenameFileModal.tsx b/src/features/instance/applications/modals/RenameFileModal.tsx index 6346b9b0d..2170399df 100644 --- a/src/features/instance/applications/modals/RenameFileModal.tsx +++ b/src/features/instance/applications/modals/RenameFileModal.tsx @@ -8,13 +8,12 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Form } from '@/components/ui/form/Form'; -import { FormControl } from '@/components/ui/form/FormControl'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; +import { LengthLimitedInput } from '@/components/ui/form/LengthLimitedInput'; import { revealFieldErrorOnEnter } from '@/components/ui/form/revealFieldErrorOnEnter'; -import { Input } from '@/components/ui/input'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import type { DirectoryEntry } from '@/features/instance/applications/context/directoryEntry'; import type { FileEntry } from '@/features/instance/applications/context/fileEntry'; @@ -180,15 +179,14 @@ export function RenameFileModal() { render={({ field }) => ( Name - - - + )} diff --git a/src/features/instance/config/roles/modals/AddRoleModal.test.tsx b/src/features/instance/config/roles/modals/AddRoleModal.test.tsx index 3c5dc8865..7f933d920 100644 --- a/src/features/instance/config/roles/modals/AddRoleModal.test.tsx +++ b/src/features/instance/config/roles/modals/AddRoleModal.test.tsx @@ -99,4 +99,13 @@ describe('AddRoleModal', () => { await screen.findByRole('radio', { name: /Super user/ }); expect(screen.queryByRole('radio', { name: /Cluster user/ })).toBeNull(); }); + + it('stops the role name at the 30 characters the form accepts, and says so there', () => { + renderModal(); + const name = screen.getByRole('textbox', { name: 'Name' }) as HTMLInputElement; + expect(name.maxLength).toBe(30); + + fireEvent.change(name, { target: { value: 'a'.repeat(30) } }); + expect(screen.getByText('Maximum 30 characters reached.')).toBeTruthy(); + }); }); diff --git a/src/features/instance/config/roles/modals/AddRoleModal.tsx b/src/features/instance/config/roles/modals/AddRoleModal.tsx index 6e5167aa9..a33bf34bb 100644 --- a/src/features/instance/config/roles/modals/AddRoleModal.tsx +++ b/src/features/instance/config/roles/modals/AddRoleModal.tsx @@ -8,12 +8,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Form } from '@/components/ui/form/Form'; -import { FormControl } from '@/components/ui/form/FormControl'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; -import { Input } from '@/components/ui/input'; +import { LengthLimitedInput } from '@/components/ui/form/LengthLimitedInput'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { isOperationsValueUnusable, @@ -99,9 +98,7 @@ export function AddRoleModal({ render={({ field }) => ( Name - - - + )} diff --git a/src/features/instance/databases/modals/CreateNewTableModal.test.tsx b/src/features/instance/databases/modals/CreateNewTableModal.test.tsx new file mode 100644 index 000000000..0e1218fa7 --- /dev/null +++ b/src/features/instance/databases/modals/CreateNewTableModal.test.tsx @@ -0,0 +1,48 @@ +/** + * @vitest-environment jsdom + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('@tanstack/react-router', () => ({ useRouter: () => ({ invalidate: vi.fn() }) })); +vi.mock('@/config/useInstanceClient', () => ({ + useInstanceClientIdParams: () => ({ entityId: 'test-instance', entityType: 'instance' }), +})); +vi.mock('@/integrations/api/instance/database/createTable', () => ({ + useCreateTableMutation: () => ({ mutate: vi.fn() }), +})); + +import { CreateNewTableModal } from './CreateNewTableModal'; + +beforeAll(() => { + // Radix Dialog relies on DOM APIs jsdom doesn't implement. + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.setPointerCapture ??= () => undefined; + Element.prototype.releasePointerCapture ??= () => undefined; + Element.prototype.scrollIntoView ??= () => undefined; +}); + +afterEach(cleanup); + +describe('CreateNewTableModal', () => { + it('stops the table name, primary key and database name at the lengths the form accepts, and says so there', () => { + render( + + {}} databaseName={undefined} onCreated={() => {}} /> + , + ); + const table = screen.getByLabelText('Table Name') as HTMLInputElement; + const primaryKey = screen.getByLabelText('Primary Key') as HTMLInputElement; + const database = screen.getByLabelText('Database Name') as HTMLInputElement; + expect(table.maxLength).toBe(250); + expect(primaryKey.maxLength).toBe(250); + expect(database.maxLength).toBe(75); + + fireEvent.change(table, { target: { value: 't'.repeat(250) } }); + fireEvent.change(primaryKey, { target: { value: 'k'.repeat(250) } }); + fireEvent.change(database, { target: { value: 'd'.repeat(75) } }); + expect(screen.getAllByText('Maximum 250 characters reached.')).toHaveLength(2); + expect(screen.getByText('Maximum 75 characters reached.')).toBeTruthy(); + }); +}); diff --git a/src/features/instance/databases/modals/CreateNewTableModal.tsx b/src/features/instance/databases/modals/CreateNewTableModal.tsx index d7a5bbcb7..b58ca0808 100644 --- a/src/features/instance/databases/modals/CreateNewTableModal.tsx +++ b/src/features/instance/databases/modals/CreateNewTableModal.tsx @@ -8,12 +8,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Form } from '@/components/ui/form/Form'; -import { FormControl } from '@/components/ui/form/FormControl'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; -import { Input } from '@/components/ui/input'; +import { LengthLimitedInput } from '@/components/ui/form/LengthLimitedInput'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { useCreateTableMutation } from '@/integrations/api/instance/database/createTable'; import { databaseNameSchema } from '@/integrations/api/instance/database/databaseNameSchema'; @@ -108,17 +107,15 @@ export function CreateNewTableModal({ isModalOpen, setIsModalOpen, databaseName, render={({ field }) => ( Table Name - - - + )} @@ -129,17 +126,15 @@ export function CreateNewTableModal({ isModalOpen, setIsModalOpen, databaseName, render={({ field }) => ( Primary Key - - - + )} @@ -150,17 +145,15 @@ export function CreateNewTableModal({ isModalOpen, setIsModalOpen, databaseName, render={({ field }) => ( Database Name - - - + )} diff --git a/src/features/instance/databases/modals/ImportDataModal.permissions.test.tsx b/src/features/instance/databases/modals/ImportDataModal.permissions.test.tsx index 2530bbc22..6123f3b5d 100644 --- a/src/features/instance/databases/modals/ImportDataModal.permissions.test.tsx +++ b/src/features/instance/databases/modals/ImportDataModal.permissions.test.tsx @@ -3,7 +3,7 @@ */ import { ImportDataModal } from '@/features/instance/databases/modals/ImportDataModal'; import { LocalRolePermission } from '@/integrations/api/api.patch'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; // Only the auth store and the router are stubbed, so these run the real @@ -129,3 +129,18 @@ describe('ImportDataModal requested method', () => { expect(selectedMethod()).toBe('import-method-url'); }); }); + +describe('ImportDataModal name fields', () => { + it('stops the database and table names at the lengths the form accepts, and says so there', () => { + renderModal({ super_user: true }); + const database = screen.getByLabelText('Database') as HTMLInputElement; + const table = screen.getByLabelText('Table') as HTMLInputElement; + expect(database.maxLength).toBe(75); + expect(table.maxLength).toBe(250); + + fireEvent.change(database, { target: { value: 'd'.repeat(75) } }); + fireEvent.change(table, { target: { value: 't'.repeat(250) } }); + expect(screen.getByText('Maximum 75 characters reached.')).toBeTruthy(); + expect(screen.getByText('Maximum 250 characters reached.')).toBeTruthy(); + }); +}); diff --git a/src/features/instance/databases/modals/ImportDataModal.tsx b/src/features/instance/databases/modals/ImportDataModal.tsx index 5ea32e843..6a55d6699 100644 --- a/src/features/instance/databases/modals/ImportDataModal.tsx +++ b/src/features/instance/databases/modals/ImportDataModal.tsx @@ -14,6 +14,7 @@ import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; +import { LengthLimitedInput } from '@/components/ui/form/LengthLimitedInput'; import { Input } from '@/components/ui/input'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radioGroup'; import { @@ -525,16 +526,15 @@ export function ImportDataModal({ render={({ field }) => ( Database - - - + )} @@ -545,15 +545,14 @@ export function ImportDataModal({ render={({ field }) => ( Table - - - + )} diff --git a/src/features/organization/roles/modals/AddOrganizationRoleModal.test.tsx b/src/features/organization/roles/modals/AddOrganizationRoleModal.test.tsx index d72188e69..1c652ac50 100644 --- a/src/features/organization/roles/modals/AddOrganizationRoleModal.test.tsx +++ b/src/features/organization/roles/modals/AddOrganizationRoleModal.test.tsx @@ -52,6 +52,15 @@ describe('AddOrganizationRoleModal', () => { expect(await screen.findByText('Role must contain only letters and underscores.')).toBeTruthy(); }); + it('stops the role name at the 30 characters the form accepts, and says so there', () => { + render( {}} />); + const name = screen.getByLabelText('Role Name') as HTMLInputElement; + expect(name.maxLength).toBe(30); + + fireEvent.change(name, { target: { value: 'a'.repeat(30) } }); + expect(screen.getByText('Maximum 30 characters reached.')).toBeTruthy(); + }); + it.each([ ['a space', 'my role', 'Role must contain only letters and underscores.'], ['more than 30 characters', 'a'.repeat(31), 'Role name cannot be longer than 30 characters.'], diff --git a/src/features/organization/roles/modals/AddOrganizationRoleModal.tsx b/src/features/organization/roles/modals/AddOrganizationRoleModal.tsx index c27896f99..458c8baec 100644 --- a/src/features/organization/roles/modals/AddOrganizationRoleModal.tsx +++ b/src/features/organization/roles/modals/AddOrganizationRoleModal.tsx @@ -6,6 +6,7 @@ import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; +import { LengthLimitedInput } from '@/components/ui/form/LengthLimitedInput'; import { revealFieldErrorOnEnter } from '@/components/ui/form/revealFieldErrorOnEnter'; import { Input } from '@/components/ui/input'; import { useAddOrganizationRole } from '@/features/organization/mutations/addOrganizationRole'; @@ -124,9 +125,12 @@ export function AddOrganizationRoleModal({ render={({ field }) => ( Role Name - - - + )} diff --git a/src/features/organization/settings/index.test.tsx b/src/features/organization/settings/index.test.tsx new file mode 100644 index 000000000..6b49df5d8 --- /dev/null +++ b/src/features/organization/settings/index.test.tsx @@ -0,0 +1,84 @@ +/** + * @vitest-environment jsdom + */ +import type { OAuthConfig } from '@/integrations/api/api.patch'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { type ReactNode, Suspense } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { get, patch } = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() })); +vi.mock('@/config/apiClient', () => ({ apiClient: { get, patch } })); +vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ organizationId: 'org-1' }) })); +vi.mock('@/hooks/useAuth', () => ({ useCloudAuth: () => ({ user: {} }) })); +vi.mock('@/hooks/usePermissions', () => ({ useOrganizationPermissions: () => ({ update: true }) })); +vi.mock('@/components/SubNavMenu', () => ({ SubNavMenu: () => null })); +vi.mock('@/features/organization/components/OrgPageLayout', () => ({ + OrgPageLayout: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { OrgSettingsIndex } from '@/features/organization/settings'; + +function renderSettings(oauthConfigs: OAuthConfig[]) { + get.mockResolvedValue({ data: { id: 'org-1', settings: { oauthConfigs } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return render( + + + + + , + ); +} + +async function openEditForm() { + fireEvent.click(await screen.findByRole('button', { name: 'Edit provider' })); +} + +function sentScope(): unknown { + return (patch.mock.calls[0][1] as Partial).scope; +} + +beforeEach(() => patch.mockResolvedValue({ data: {} })); +afterEach(() => { + cleanup(); + vi.resetAllMocks(); +}); + +describe('OrgSettingsIndex β€” OAuth scope', () => { + it('sends the scope its placeholder shows when an added provider clears Scope to whitespace', async () => { + renderSettings([]); + fireEvent.click(await screen.findByRole('button', { name: /add provider/i })); + fireEvent.change(screen.getByLabelText('Domain'), { target: { value: 'acme.okta.com' } }); + fireEvent.change(screen.getByLabelText('Client ID'), { target: { value: 'client' } }); + fireEvent.change(screen.getByLabelText('Client Secret'), { target: { value: 'secret' } }); + const scope = screen.getByLabelText('Scope') as HTMLInputElement; + fireEvent.change(scope, { target: { value: ' ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add Provider' })); + + await waitFor(() => expect(patch).toHaveBeenCalledOnce()); + expect(sentScope()).toBe(scope.placeholder); + expect(sentScope()).toBe('openid email profile'); + }); + + it('sends the default when an edited provider has no stored scope and Scope stays blank', async () => { + renderSettings([{ id: 'oac-1', provider: 'okta', domain: 'acme.okta.com', clientId: '****' }]); + await openEditForm(); + expect((screen.getByLabelText('Scope') as HTMLInputElement).value).toBe(''); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(patch).toHaveBeenCalledOnce()); + expect(sentScope()).toBe('openid email profile'); + }); + + it('keeps a scope the admin typed', async () => { + renderSettings([{ id: 'oac-1', provider: 'okta', domain: 'acme.okta.com', clientId: '****', scope: 'openid' }]); + await openEditForm(); + fireEvent.change(screen.getByLabelText('Scope'), { target: { value: 'openid email groups' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(patch).toHaveBeenCalledOnce()); + expect(sentScope()).toBe('openid email groups'); + }); +}); diff --git a/src/features/organization/settings/index.tsx b/src/features/organization/settings/index.tsx index 0631dd8d1..62b910dc1 100644 --- a/src/features/organization/settings/index.tsx +++ b/src/features/organization/settings/index.tsx @@ -37,6 +37,10 @@ async function patchOAuthConfig(orgId: string, payload: Partial & { return data as unknown as OAuthConfig; } +// Central manager stores the scope verbatim and it overrides the provider preset, so a blank field +// would sign in with an empty scope: submit the default its placeholder shows instead. +const DEFAULT_OAUTH_SCOPE = 'openid email profile'; + // ── Add form ────────────────────────────────────────────────────────────────── const addSchema = z.object({ @@ -82,12 +86,12 @@ export function OrgSettingsIndex() { // Add const addForm = useForm({ resolver: zodResolver(addSchema), - defaultValues: { domain: '', clientId: '', clientSecret: '', scope: 'openid email profile' }, + defaultValues: { domain: '', clientId: '', clientSecret: '', scope: DEFAULT_OAUTH_SCOPE }, }); const onAdd = (data: AddForm) => { patch( - { provider: 'okta', ...data }, + { provider: 'okta', ...data, scope: data.scope?.trim() || DEFAULT_OAUTH_SCOPE }, { onSuccess: () => { toast.success('OAuth provider added.'); @@ -111,7 +115,7 @@ export function OrgSettingsIndex() { id: config.id!, provider: config.provider, domain: data.domain, - scope: data.scope, + scope: data.scope?.trim() || DEFAULT_OAUTH_SCOPE, }; // Only include secrets if the user actually typed something (blank = keep existing) if (data.clientId) { payload.clientId = data.clientId; } @@ -207,7 +211,7 @@ export function OrgSettingsIndex() { Domain - + @@ -246,7 +250,7 @@ export function OrgSettingsIndex() { Scope - + @@ -377,6 +381,7 @@ export function OrgSettingsIndex() {