From 33d3b6348f96eb81bb4e7a2c9e4eb9de8fe33d7f Mon Sep 17 00:00:00 2001 From: denyslevada Date: Wed, 1 Jul 2026 18:56:56 +0300 Subject: [PATCH 1/7] refactor(sign-in): migrate sign-in form and section-code entry to DSCO Replace the server-rendered sign-in form and section-code entry with React components using DSCO TextField, MUI Button/Typography, and semantic color tokens, matching the sign-up flow. OAuth buttons, course blocks, and the LTI account-linking variant are unchanged. - New SignInForm + SectionCodeEntry (apps/src/signIn/) with a layout-only module.scss (semantic tokens, no hex/legacy vars) - Mount both from devise/sessions/_login.js; retire the old jQuery handlers (userReturnTo write moves into SignInForm's effect) - Retire #signin / .section-sign-in CSS from application.scss; keep .flex-container, .vertical-or, #change-password and .blue-button (shared with the out-of-scope LTI variant / other pages) - Preserve the UI-test DOM hooks (#signin, #user_login, #user_password, #signin-button, #section_code); update one stale cucumber selector - Keep show_flashes server-rendered so failed-login errors still surface Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/src/signIn/SectionCodeEntry.tsx | 58 ++++++++ apps/src/signIn/SignInForm.tsx | 140 ++++++++++++++++++ apps/src/signIn/signInStyles.module.scss | 53 +++++++ .../studio/pages/devise/sessions/_login.js | 52 +++++-- .../test/unit/signIn/SectionCodeEntryTest.tsx | 48 ++++++ apps/test/unit/signIn/SignInFormTest.tsx | 122 +++++++++++++++ .../app/assets/stylesheets/application.scss | 48 +----- .../views/devise/sessions/_login.html.haml | 54 +++---- .../app/views/devise/shared/_oauth_links.haml | 15 +- .../ui/features/platform/signing_in.feature | 2 +- 10 files changed, 504 insertions(+), 88 deletions(-) create mode 100644 apps/src/signIn/SectionCodeEntry.tsx create mode 100644 apps/src/signIn/SignInForm.tsx create mode 100644 apps/src/signIn/signInStyles.module.scss create mode 100644 apps/test/unit/signIn/SectionCodeEntryTest.tsx create mode 100644 apps/test/unit/signIn/SignInFormTest.tsx diff --git a/apps/src/signIn/SectionCodeEntry.tsx b/apps/src/signIn/SectionCodeEntry.tsx new file mode 100644 index 0000000000000..b6f109739f385 --- /dev/null +++ b/apps/src/signIn/SectionCodeEntry.tsx @@ -0,0 +1,58 @@ +import TextField from '@code-dot-org/component-library/textField'; +import {Typography, Button as MuiButton} from '@mui/material'; +import React, {useState} from 'react'; + +import style from './signInStyles.module.scss'; + +export interface SectionCodeEntryProps { + // Visible heading above the field, e.g. "Enter your 6 letter section code". + sectionCodeHeading: string; + // Accessible name for the input; the field has no visible label of its own + // to avoid duplicating the heading text. + sectionCodeLabel: string; + sectionCodePlaceholder: string; + defaultSectionCode: string; + goLabel: string; + // Where the GET form submits (student_user_new_path -> /users/new). + formAction: string; +} + +const SectionCodeEntry: React.FunctionComponent = ({ + sectionCodeHeading, + sectionCodeLabel, + sectionCodePlaceholder, + defaultSectionCode, + goLabel, + formAction, +}) => { + const [sectionCode, setSectionCode] = useState(defaultSectionCode || ''); + + return ( +
+ + {sectionCodeHeading} + +
+ setSectionCode(e.target.value)} + /> + + {goLabel} + + +
+ ); +}; + +export default SectionCodeEntry; diff --git a/apps/src/signIn/SignInForm.tsx b/apps/src/signIn/SignInForm.tsx new file mode 100644 index 0000000000000..4daf88fe4717d --- /dev/null +++ b/apps/src/signIn/SignInForm.tsx @@ -0,0 +1,140 @@ +import Link from '@code-dot-org/component-library/link'; +import TextField from '@code-dot-org/component-library/textField'; +import {Button as MuiButton} from '@mui/material'; +import React, {useEffect, useState} from 'react'; + +import RailsAuthenticityToken from '@cdo/apps/lib/util/RailsAuthenticityToken'; +import {EVENTS} from '@cdo/apps/metrics/AnalyticsConstants'; +import analyticsReporter from '@cdo/apps/metrics/AnalyticsReporter'; +import {USER_RETURN_TO_SESSION_KEY} from '@cdo/apps/signUpFlow/signUpFlowConstants'; + +import style from './signInStyles.module.scss'; + +// Devise session endpoint. The field names below (user[login], +// user[password], user[hashed_email]) match what the Rails controller expects. +const SIGN_IN_PATH = '/users/sign_in'; + +// These ids double as the DOM hooks the UI (Cucumber) tests drive: +// #signin (wrapper), #user_login, #user_password, #signin-button. +const LOGIN_FIELD_ID = 'user_login'; +const PASSWORD_FIELD_ID = 'user_password'; + +export interface SignInFormProps { + // hashed_email is populated server-side (on failed-login re-render) and + // threaded through untouched -- sign-in never hashes the email client-side. + hashedEmail: string; + // Pre-populated login value (from @email), if any. + loginValue: string; + loginLabel: string; + passwordLabel: string; + signInLabel: string; + signUpLabel: string; + signUpPath: string; + // Whether the "Sign up" button should render (devise_mapping.registerable?). + showSignUp: boolean; + // Devise password-reset path; absent when the mapping is not recoverable. + forgotPasswordPath?: string; + forgotPasswordLabel?: string; + // Written to sessionStorage on mount when present, before any redirect. + userReturnTo?: string | null; +} + +const SignInForm: React.FunctionComponent = ({ + hashedEmail, + loginValue, + loginLabel, + passwordLabel, + signInLabel, + signUpLabel, + signUpPath, + showSignUp, + forgotPasswordPath, + forgotPasswordLabel, + userReturnTo, +}) => { + const [login, setLogin] = useState(loginValue || ''); + const [password, setPassword] = useState(''); + + // Match the legacy autofocus behavior: focus the login field when there is + // no pre-filled email, otherwise focus the password field. Done via an + // effect rather than the autoFocus prop (which jsx-a11y disallows). + const emailPrefilled = (loginValue || '') !== ''; + + useEffect(() => { + if (userReturnTo) { + sessionStorage.setItem(USER_RETURN_TO_SESSION_KEY, userReturnTo); + } + }, [userReturnTo]); + + useEffect(() => { + const id = emailPrefilled ? PASSWORD_FIELD_ID : LOGIN_FIELD_ID; + document.getElementById(id)?.focus(); + }, [emailPrefilled]); + + return ( +
+
+ + + + setLogin(e.target.value)} + autoComplete="username" + /> + setPassword(e.target.value)} + autoComplete="current-password" + /> + + {forgotPasswordPath && ( + + {forgotPasswordLabel} + + )} + + + {signInLabel} + + + + {showSignUp && ( + + analyticsReporter.sendEvent( + EVENTS.LOGIN_PAGE_CREATE_ACCOUNT_CLICKED, + {} + ) + } + > + {signUpLabel} + + )} +
+ ); +}; + +export default SignInForm; diff --git a/apps/src/signIn/signInStyles.module.scss b/apps/src/signIn/signInStyles.module.scss new file mode 100644 index 0000000000000..0ff971f69c84d --- /dev/null +++ b/apps/src/signIn/signInStyles.module.scss @@ -0,0 +1,53 @@ +// Layout for the sign-in page. The DSCO/MUI components own their own colors +// through semantic tokens, so this module owns layout only -- there are no raw +// hex values or legacy SCSS color variables here. + +.signInColumn { + display: flex; + flex-direction: column; + align-items: stretch; + flex-grow: 2; + gap: 1rem; + min-width: 20rem; + max-width: 28rem; +} + +.form { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 0; +} + +.field { + width: 100%; + + input { + width: 100%; + } +} + +.forgotPassword { + align-self: flex-start; +} + +.sectionCode { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sectionCodeForm { + display: flex; + align-items: flex-end; + gap: 0.5rem; + margin: 0; +} + +.sectionCodeField { + flex-grow: 1; + + input { + width: 100%; + } +} diff --git a/apps/src/sites/studio/pages/devise/sessions/_login.js b/apps/src/sites/studio/pages/devise/sessions/_login.js index 9416bab00ed54..52d997ba05d61 100644 --- a/apps/src/sites/studio/pages/devise/sessions/_login.js +++ b/apps/src/sites/studio/pages/devise/sessions/_login.js @@ -1,21 +1,55 @@ import $ from 'jquery'; +import React from 'react'; import {EVENTS} from '@cdo/apps/metrics/AnalyticsConstants'; import analyticsReporter from '@cdo/apps/metrics/AnalyticsReporter'; -import {USER_RETURN_TO_SESSION_KEY} from '@cdo/apps/signUpFlow/signUpFlowConstants'; -import getScriptData from '@cdo/apps/util/getScriptData'; +import SectionCodeEntry from '@cdo/apps/signIn/SectionCodeEntry'; +import SignInForm from '@cdo/apps/signIn/SignInForm'; +import {createReactRoot} from '@cdo/apps/util/createReactRoot'; $(document).ready(() => { - const userReturnTo = getScriptData('userReturnTo'); - - if (userReturnTo) { - sessionStorage.setItem(USER_RETURN_TO_SESSION_KEY, userReturnTo); + const signInMount = document.getElementById('sign-in-page-layout'); + if (signInMount) { + const data = signInMount.dataset; + createReactRoot( + , + signInMount, + {legacyReactDomRender: true} + ); } - document.getElementById('user_signup').addEventListener('click', () => { - analyticsReporter.sendEvent(EVENTS.LOGIN_PAGE_CREATE_ACCOUNT_CLICKED, {}); - }); + // Only present on the sessions (sign-in) page. + const sectionCodeMount = document.getElementById('section-code-entry-mount'); + if (sectionCodeMount) { + const data = sectionCodeMount.dataset; + createReactRoot( + , + sectionCodeMount, + {legacyReactDomRender: true} + ); + } + // Course blocks remain server-rendered HAML for now, so keep their + // click analytics wiring here until they are migrated separately. const courseBlocks = document.querySelectorAll('.courseblock-tall'); courseBlocks.forEach(courseBlock => { const courseTitle = courseBlock.querySelector('h3').textContent; diff --git a/apps/test/unit/signIn/SectionCodeEntryTest.tsx b/apps/test/unit/signIn/SectionCodeEntryTest.tsx new file mode 100644 index 0000000000000..1d97d72bbf17a --- /dev/null +++ b/apps/test/unit/signIn/SectionCodeEntryTest.tsx @@ -0,0 +1,48 @@ +import {render, screen} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import SectionCodeEntry, { + SectionCodeEntryProps, +} from '@cdo/apps/signIn/SectionCodeEntry'; + +const DEFAULT_PROPS: SectionCodeEntryProps = { + sectionCodeHeading: 'Enter your 6 letter section code', + sectionCodeLabel: 'Enter your 6 letter section code', + sectionCodePlaceholder: 'Section Code (ABCDEF)', + defaultSectionCode: '', + goLabel: 'Go', + formAction: '/users/new', +}; + +describe('SectionCodeEntry', () => { + function renderEntry(overrides: Partial = {}) { + return render(); + } + + it('renders the heading, section-code input, and Go button', () => { + renderEntry(); + + screen.getByRole('heading', {name: DEFAULT_PROPS.sectionCodeHeading}); + expect( + screen.getByRole('textbox', {name: DEFAULT_PROPS.sectionCodeLabel}) + ).toHaveAttribute('name', 'section_code'); + screen.getByRole('button', {name: DEFAULT_PROPS.goLabel}); + }); + + it('pre-populates the input with defaultSectionCode when provided', () => { + renderEntry({defaultSectionCode: 'ABCDEF'}); + expect( + screen.getByRole('textbox', {name: DEFAULT_PROPS.sectionCodeLabel}) + ).toHaveValue('ABCDEF'); + }); + + it('submits via GET to the provided form action', () => { + renderEntry({formAction: '/users/new'}); + const form = screen + .getByRole('button', {name: DEFAULT_PROPS.goLabel}) + .closest('form'); + expect(form).toHaveAttribute('action', '/users/new'); + expect(form).toHaveAttribute('method', 'get'); + }); +}); diff --git a/apps/test/unit/signIn/SignInFormTest.tsx b/apps/test/unit/signIn/SignInFormTest.tsx new file mode 100644 index 0000000000000..60539200646cb --- /dev/null +++ b/apps/test/unit/signIn/SignInFormTest.tsx @@ -0,0 +1,122 @@ +import {render, screen, fireEvent} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import {EVENTS} from '@cdo/apps/metrics/AnalyticsConstants'; +import analyticsReporter from '@cdo/apps/metrics/AnalyticsReporter'; +import SignInForm, {SignInFormProps} from '@cdo/apps/signIn/SignInForm'; +import {USER_RETURN_TO_SESSION_KEY} from '@cdo/apps/signUpFlow/signUpFlowConstants'; + +const DEFAULT_PROPS: SignInFormProps = { + hashedEmail: 'hashed-abc', + loginValue: '', + loginLabel: 'Email address or username', + passwordLabel: 'Password', + signInLabel: 'Sign in', + signUpLabel: 'Sign up', + signUpPath: '/users/sign_up/account_type', + showSignUp: true, + forgotPasswordPath: '/users/password/new', + forgotPasswordLabel: 'Forgot password?', + userReturnTo: null, +}; + +describe('SignInForm', () => { + beforeAll(() => { + // RailsAuthenticityToken reads these meta tags; provide them so it renders + // the token input instead of logging a missing-tags error. + document.head.innerHTML = + '' + + ''; + }); + + beforeEach(() => { + sessionStorage.clear(); + }); + + function renderForm(overrides: Partial = {}) { + return render(); + } + + it('renders login and password fields and the sign-in button', () => { + renderForm(); + + expect(screen.getByLabelText(DEFAULT_PROPS.loginLabel)).toHaveAttribute( + 'name', + 'user[login]' + ); + expect(screen.getByLabelText(DEFAULT_PROPS.passwordLabel)).toHaveAttribute( + 'name', + 'user[password]' + ); + screen.getByRole('button', {name: DEFAULT_PROPS.signInLabel}); + }); + + it('posts to /users/sign_in', () => { + renderForm(); + const form = screen + .getByRole('button', {name: DEFAULT_PROPS.signInLabel}) + .closest('form'); + expect(form).toHaveAttribute('action', '/users/sign_in'); + expect(form).toHaveAttribute('method', 'post'); + }); + + it('threads the hashed_email through as a hidden field, unchanged', () => { + renderForm({hashedEmail: 'server-set-hash'}); + const hidden = screen.getByDisplayValue('server-set-hash'); + expect(hidden).toHaveAttribute('name', 'user[hashed_email]'); + expect(hidden).toHaveAttribute('type', 'hidden'); + }); + + it('renders the sign-up button and fires the analytics event on click', () => { + const sendEventSpy = jest.spyOn(analyticsReporter, 'sendEvent'); + renderForm(); + + fireEvent.click(screen.getByText(DEFAULT_PROPS.signUpLabel)); + + expect(sendEventSpy).toHaveBeenCalledWith( + EVENTS.LOGIN_PAGE_CREATE_ACCOUNT_CLICKED, + {} + ); + sendEventSpy.mockRestore(); + }); + + it('does not render the sign-up button when showSignUp is false', () => { + renderForm({showSignUp: false}); + expect(screen.queryByText(DEFAULT_PROPS.signUpLabel)).toBeNull(); + }); + + it('points the forgot-password link at forgotPasswordPath', () => { + renderForm({ + forgotPasswordPath: '/users/password/new', + forgotPasswordLabel: 'Forgot password?', + }); + expect( + screen.getByRole('link', {name: 'Forgot password?'}) + ).toHaveAttribute('href', '/users/password/new'); + }); + + it('omits the forgot-password link when no path is provided', () => { + renderForm({forgotPasswordPath: undefined}); + expect( + screen.queryByText(DEFAULT_PROPS.forgotPasswordLabel as string) + ).toBeNull(); + }); + + it('writes userReturnTo to sessionStorage on mount when set', () => { + renderForm({userReturnTo: '/home'}); + expect(sessionStorage.getItem(USER_RETURN_TO_SESSION_KEY)).toBe('/home'); + }); + + it('does not write userReturnTo to sessionStorage when absent', () => { + renderForm({userReturnTo: null}); + expect(sessionStorage.getItem(USER_RETURN_TO_SESSION_KEY)).toBeNull(); + }); + + it('pre-populates the login field with loginValue', () => { + renderForm({loginValue: 'teacher@example.com'}); + expect(screen.getByLabelText(DEFAULT_PROPS.loginLabel)).toHaveValue( + 'teacher@example.com' + ); + }); +}); diff --git a/dashboard/app/assets/stylesheets/application.scss b/dashboard/app/assets/stylesheets/application.scss index a1d32ffcaaf47..63c828a0993de 100644 --- a/dashboard/app/assets/stylesheets/application.scss +++ b/dashboard/app/assets/stylesheets/application.scss @@ -1878,7 +1878,9 @@ a.download-video { max-width: 600px; } -#signin, +// Sign-in styles retired: the sign-in form now lives in React +// (apps/src/signIn/SignInForm.tsx) with its own module.scss. These rules are +// kept only for #change-password (the password-reset page). #change-password { $field-width: 310px; $button-width: 324px; @@ -2405,48 +2407,8 @@ a.download-video { margin: 0; } - .section-sign-in { - display: flex; - align-items: center; - - input { - background-color: $neutral_light; - } - - input[type=text] { - flex-grow: 2; - height: 40px; - padding-top: 0; - padding-bottom: 0; - margin: 0; - } - - button { - @include main-font-semi-bold; - padding: 10px 20px; - margin-right: 0; - background-color: $brand_secondary_default; - border: 2px solid $brand_secondary_default; - color: $neutral_white; - - &:hover { - background-color: $brand_secondary_dark; - border-color: $brand_secondary_dark; - box-shadow: none; - } - &:focus { - border-color: $brand_primary_default; - } - - &:disabled { - color: $neutral_white; - border-color: $neutral_dark20; - background-color: $neutral_dark20; - @include box-shadow(none); - cursor: not-allowed; - } - } - } + // .section-sign-in retired: section-code entry now lives in React + // (apps/src/signIn/SectionCodeEntry.tsx). .oauth-sign-in { @include main-font-semi-bold; diff --git a/dashboard/app/views/devise/sessions/_login.html.haml b/dashboard/app/views/devise/sessions/_login.html.haml index 2f68b64f4fd2a..72cfb0e68501c 100644 --- a/dashboard/app/views/devise/sessions/_login.html.haml +++ b/dashboard/app/views/devise/sessions/_login.html.haml @@ -1,39 +1,31 @@ - content_for :head do - :ruby - script_data = {userReturnTo: @user_return_to.presence.to_json} - %script{src: webpack_asset_path('js/devise/sessions/_login.js'), data: script_data} + %script{src: webpack_asset_path('js/devise/sessions/_login.js')} %h2= t('signin_form.title') +/ Login errors (e.g. "invalid password") are still delivered via the Rails +/ flash. show_flashes consumes the flash the first time it is called, so +/ keeping it here (the view renders before the layout) is what actually +/ surfaces the error on this page. Flash migration is a separate ticket. += show_flashes.html_safe + .flex-container - #signin - = form_for(resource, :as => resource_name, :url => session_path(resource_name)) do |f| - = show_flashes.html_safe - = f.hidden_field :hashed_email - - / Email - .field - = label_tag t('signin_form.login_username') - - email = @email || '' - = f.text_field :login, value: email, autofocus: email == '' - - / Password - .field#password_field - = f.label :password - = f.password_field :password, autofocus: email != '' - - / Forgot password? - - if devise_mapping.recoverable? - %div.field-aligned.password_help_link - = link_to t('auth.forgot_password'), new_password_path(resource_name), id: 'forgot-password' - - / Sign in button - %button#signin-button= t('signin_form.submit') - - / Not yet signed up? Sign up - - if devise_mapping.registerable? - %a#signup-link.field-aligned{href: users_sign_up_account_type_path} - %button.neutral-button{:id => 'user_signup'}= t('nav.user.signup') + / Left column: sign-in form + sign-up button (React). display:contents makes + / the mounted component's own root, not this div, the flex child. + #sign-in-page-layout{style: 'display: contents', + data: { + hashed_email: resource.hashed_email, + login_value: (@email || ''), + login_label: t('signin_form.login_username'), + password_label: resource.class.human_attribute_name(:password), + sign_in_label: t('signin_form.submit'), + sign_up_label: t('nav.user.signup'), + sign_up_path: users_sign_up_account_type_path, + show_sign_up: devise_mapping.registerable?.to_s, + forgot_password_path: (new_password_path(resource_name) if devise_mapping.recoverable?), + forgot_password_label: t('auth.forgot_password'), + user_return_to: @user_return_to.presence, + }} %div.vertical-or %hr diff --git a/dashboard/app/views/devise/shared/_oauth_links.haml b/dashboard/app/views/devise/shared/_oauth_links.haml index 56ea2c0b24853..96e65c2050ff8 100644 --- a/dashboard/app/views/devise/shared/_oauth_links.haml +++ b/dashboard/app/views/devise/shared/_oauth_links.haml @@ -9,10 +9,17 @@ %br/ - if controller_name == 'sessions' - %h6= t('join_section.code.instructions_short') - = form_tag(student_user_new_path, method: :get, class: 'section-sign-in') do - = text_field_tag :section_code, params[:section_code], placeholder: t('join_section.code.placeholder') - %button= 'Go' + / Section-code entry (React). Mounted by devise/sessions/_login.js. + #section-code-entry-mount{ + data: { + section_code_heading: t('join_section.code.instructions_short'), + section_code_label: t('join_section.code.instructions_short'), + section_code_placeholder: t('join_section.code.placeholder'), + default_section_code: (params[:section_code] || ''), + go_label: 'Go', + form_action: student_user_new_path, + } + } - if devise_mapping.omniauthable? - unless Rails.env.production? diff --git a/dashboard/test/ui/features/platform/signing_in.feature b/dashboard/test/ui/features/platform/signing_in.feature index 882562d18b69e..c24c305eeec24 100644 --- a/dashboard/test/ui/features/platform/signing_in.feature +++ b/dashboard/test/ui/features/platform/signing_in.feature @@ -44,5 +44,5 @@ Scenario: Signed-out joining non-picture non-word section from sign in page goes Given I sign out Given I am on "http://studio.code.org/users/sign_in/" And I type the section code into "#section_code" - And I click ".section-sign-in button" to load a new page + And I click "#section_code_submit" to load a new page And I wait until element "a:contains(Create an account)" is visible From 91228c28578d542a17ee8f8b20f2d3b1627c060c Mon Sep 17 00:00:00 2001 From: denyslevada Date: Thu, 2 Jul 2026 18:28:10 +0300 Subject: [PATCH 2/7] fix(sign-in): balance sign-in and OAuth column widths The sign-in column was capped at max-width 28rem while the OAuth column keeps flex-grow: 2 uncapped, so the OAuth column grew to fill the row and dwarfed the form. Restore the even split by matching the legacy #signin sizing (flex-grow: 2, min-width: 450px) and constrain the form content to a readable width via a .formArea wrapper. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/src/signIn/SignInForm.tsx | 114 ++++++++++++----------- apps/src/signIn/signInStyles.module.scss | 15 ++- 2 files changed, 69 insertions(+), 60 deletions(-) diff --git a/apps/src/signIn/SignInForm.tsx b/apps/src/signIn/SignInForm.tsx index 4daf88fe4717d..5a6724cbdf953 100644 --- a/apps/src/signIn/SignInForm.tsx +++ b/apps/src/signIn/SignInForm.tsx @@ -73,66 +73,68 @@ const SignInForm: React.FunctionComponent = ({ return (
-
- - +
+ + + - setLogin(e.target.value)} - autoComplete="username" - /> - setPassword(e.target.value)} - autoComplete="current-password" - /> + setLogin(e.target.value)} + autoComplete="username" + /> + setPassword(e.target.value)} + autoComplete="current-password" + /> - {forgotPasswordPath && ( - - {forgotPasswordLabel} - - )} + {forgotPasswordPath && ( + + {forgotPasswordLabel} + + )} - - {signInLabel} - - + + {signInLabel} + + - {showSignUp && ( - - analyticsReporter.sendEvent( - EVENTS.LOGIN_PAGE_CREATE_ACCOUNT_CLICKED, - {} - ) - } - > - {signUpLabel} - - )} + {showSignUp && ( + + analyticsReporter.sendEvent( + EVENTS.LOGIN_PAGE_CREATE_ACCOUNT_CLICKED, + {} + ) + } + > + {signUpLabel} + + )} +
); }; diff --git a/apps/src/signIn/signInStyles.module.scss b/apps/src/signIn/signInStyles.module.scss index 0ff971f69c84d..e0e0a2ab5d597 100644 --- a/apps/src/signIn/signInStyles.module.scss +++ b/apps/src/signIn/signInStyles.module.scss @@ -4,12 +4,19 @@ .signInColumn { display: flex; - flex-direction: column; - align-items: stretch; + // Match the legacy #signin min-width so the sign-in and OAuth columns split + // the row evenly. Without it the OAuth column (flex-grow: 2, uncapped) grows + // to fill the extra space and dwarfs the sign-in form. flex-grow: 2; + min-width: 450px; +} + +.formArea { + display: flex; + flex-direction: column; gap: 1rem; - min-width: 20rem; - max-width: 28rem; + width: 100%; + max-width: 24rem; } .form { From b9624dd94f10887aad2cd633fffcc993268ebd62 Mon Sep 17 00:00:00 2001 From: denyslevada Date: Thu, 2 Jul 2026 18:47:03 +0300 Subject: [PATCH 3/7] refactor(sign-in): render the page title as MUI Typography Move the "Sign in" page title out of the server-rendered HAML

into a React MUI Typography (variant h2) mounted at #sign-in-title, so it follows the design system's type scale like the rest of the migrated page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sites/studio/pages/devise/sessions/_login.js | 14 ++++++++++++++ .../app/views/devise/sessions/_login.html.haml | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/src/sites/studio/pages/devise/sessions/_login.js b/apps/src/sites/studio/pages/devise/sessions/_login.js index 52d997ba05d61..8c0ba1dd43ff2 100644 --- a/apps/src/sites/studio/pages/devise/sessions/_login.js +++ b/apps/src/sites/studio/pages/devise/sessions/_login.js @@ -1,3 +1,4 @@ +import {Typography} from '@mui/material'; import $ from 'jquery'; import React from 'react'; @@ -8,6 +9,19 @@ import SignInForm from '@cdo/apps/signIn/SignInForm'; import {createReactRoot} from '@cdo/apps/util/createReactRoot'; $(document).ready(() => { + // Page title (full-width, above both columns). Rendered as MUI Typography so + // it matches the design system rather than the legacy global

styles. + const titleMount = document.getElementById('sign-in-title'); + if (titleMount) { + createReactRoot( + + {titleMount.dataset.title} + , + titleMount, + {legacyReactDomRender: true} + ); + } + const signInMount = document.getElementById('sign-in-page-layout'); if (signInMount) { const data = signInMount.dataset; diff --git a/dashboard/app/views/devise/sessions/_login.html.haml b/dashboard/app/views/devise/sessions/_login.html.haml index 72cfb0e68501c..c84728aa566c9 100644 --- a/dashboard/app/views/devise/sessions/_login.html.haml +++ b/dashboard/app/views/devise/sessions/_login.html.haml @@ -1,7 +1,8 @@ - content_for :head do %script{src: webpack_asset_path('js/devise/sessions/_login.js')} -%h2= t('signin_form.title') +/ Page title, rendered as MUI Typography by devise/sessions/_login.js. +#sign-in-title{data: {title: t('signin_form.title')}} / Login errors (e.g. "invalid password") are still delivered via the Rails / flash. show_flashes consumes the flash the first time it is called, so From 0c09228b06a9d39401dc4b4d20baf587a1e02e6e Mon Sep 17 00:00:00 2001 From: denyslevada Date: Thu, 2 Jul 2026 19:04:47 +0300 Subject: [PATCH 4/7] refactor(sign-up): update button color to tertiary and clean up styles --- apps/src/signIn/signInStyles.module.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/src/signIn/signInStyles.module.scss b/apps/src/signIn/signInStyles.module.scss index e0e0a2ab5d597..3c6c462769830 100644 --- a/apps/src/signIn/signInStyles.module.scss +++ b/apps/src/signIn/signInStyles.module.scss @@ -16,7 +16,6 @@ flex-direction: column; gap: 1rem; width: 100%; - max-width: 24rem; } .form { From 3149c743b3c9b86014790781f2d32ab0a0980c51 Mon Sep 17 00:00:00 2001 From: denyslevada Date: Thu, 2 Jul 2026 19:11:53 +0300 Subject: [PATCH 5/7] fix(sign-in): align section-code label with the sign-in field labels Render the section-code label via the TextField's own label prop instead of a separate Typography h6, so both columns use the identical DSCO field label (same size, weight, padding, and top alignment). Also give the dev-only OAuth notice a little vertical breathing room (display:block + margin-block) between the section-code field and the OAuth buttons. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/src/signIn/SectionCodeEntry.tsx | 15 +++++---------- .../sites/studio/pages/devise/sessions/_login.js | 1 - apps/test/unit/signIn/SectionCodeEntryTest.tsx | 4 +--- .../app/views/devise/shared/_oauth_links.haml | 4 ++-- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/apps/src/signIn/SectionCodeEntry.tsx b/apps/src/signIn/SectionCodeEntry.tsx index b6f109739f385..dbb66eb394b66 100644 --- a/apps/src/signIn/SectionCodeEntry.tsx +++ b/apps/src/signIn/SectionCodeEntry.tsx @@ -1,14 +1,13 @@ import TextField from '@code-dot-org/component-library/textField'; -import {Typography, Button as MuiButton} from '@mui/material'; +import {Button as MuiButton} from '@mui/material'; import React, {useState} from 'react'; import style from './signInStyles.module.scss'; export interface SectionCodeEntryProps { - // Visible heading above the field, e.g. "Enter your 6 letter section code". - sectionCodeHeading: string; - // Accessible name for the input; the field has no visible label of its own - // to avoid duplicating the heading text. + // Field label, e.g. "Enter your 6 letter section code". Rendered as the + // TextField's own label so it matches the sign-in fields' labels exactly + // (same size, weight, padding, and top alignment). sectionCodeLabel: string; sectionCodePlaceholder: string; defaultSectionCode: string; @@ -18,7 +17,6 @@ export interface SectionCodeEntryProps { } const SectionCodeEntry: React.FunctionComponent = ({ - sectionCodeHeading, sectionCodeLabel, sectionCodePlaceholder, defaultSectionCode, @@ -29,15 +27,12 @@ const SectionCodeEntry: React.FunctionComponent = ({ return (
- - {sectionCodeHeading} -
setSectionCode(e.target.value)} diff --git a/apps/src/sites/studio/pages/devise/sessions/_login.js b/apps/src/sites/studio/pages/devise/sessions/_login.js index 8c0ba1dd43ff2..b6fb0dc2a1dae 100644 --- a/apps/src/sites/studio/pages/devise/sessions/_login.js +++ b/apps/src/sites/studio/pages/devise/sessions/_login.js @@ -50,7 +50,6 @@ $(document).ready(() => { const data = sectionCodeMount.dataset; createReactRoot( { return render(); } - it('renders the heading, section-code input, and Go button', () => { + it('renders the labeled section-code input and Go button', () => { renderEntry(); - screen.getByRole('heading', {name: DEFAULT_PROPS.sectionCodeHeading}); expect( screen.getByRole('textbox', {name: DEFAULT_PROPS.sectionCodeLabel}) ).toHaveAttribute('name', 'section_code'); diff --git a/dashboard/app/views/devise/shared/_oauth_links.haml b/dashboard/app/views/devise/shared/_oauth_links.haml index 96e65c2050ff8..1aa3599ae6881 100644 --- a/dashboard/app/views/devise/shared/_oauth_links.haml +++ b/dashboard/app/views/devise/shared/_oauth_links.haml @@ -12,7 +12,6 @@ / Section-code entry (React). Mounted by devise/sessions/_login.js. #section-code-entry-mount{ data: { - section_code_heading: t('join_section.code.instructions_short'), section_code_label: t('join_section.code.instructions_short'), section_code_placeholder: t('join_section.code.placeholder'), default_section_code: (params[:section_code] || ''), @@ -23,7 +22,8 @@ - if devise_mapping.omniauthable? - unless Rails.env.production? - %strong + / display:block is required so the vertical margin applies (strong is inline). + %strong{style: 'display: block; margin-block: 0.5rem'} Hi! If you are having trouble with OAuth features on = "#{Rails.env}," consult the "Developer OAuth Setup on Localhost" doc in the Engineering drive folder From d3c051f721a7273b28f410964cdf942f0d28c706 Mon Sep 17 00:00:00 2001 From: denyslevada Date: Thu, 2 Jul 2026 21:54:28 +0300 Subject: [PATCH 6/7] fix(sign-in): preserve regional form action, form#new_user, and legacy #signin styles Addresses the drone failures and PR review feedback: - GlobalEditionTest / fa sign_in_page: the form action was hard-coded to /users/sign_in, dropping the region/locale prefix (e.g. /fa/users/sign_in). Pass session_path(resource_name) from the server via the mount's data-sign-in-path and use it as the form action; restore id="new_user". Update global_edition_test to assert the region-versioned URL on the mount (the form is React-rendered now). - #signin regression: several server-rendered pages (existing_account, PD/Foorm logged-out) still use id="signin" and relied on the shared block. Move that styling to a .signin-legacy class those pages opt into, so the React sign-in page (which reuses #signin as a test hook) doesn't inherit legacy form/button rules and the other pages keep their styling. - Guard the forgot-password link on both path and label to avoid an empty link. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/src/signIn/SignInForm.tsx | 18 +++++++++++++----- .../studio/pages/devise/sessions/_login.js | 1 + apps/test/unit/signIn/SignInFormTest.tsx | 8 +++++--- .../app/assets/stylesheets/application.scss | 10 +++++++--- .../registrations/existing_account.html.haml | 2 +- .../app/views/devise/sessions/_login.html.haml | 1 + .../foorm/simple_survey_forms/logged_out.haml | 2 +- .../teacher_application/logged_out.haml | 2 +- .../app/views/pd/misc_survey/logged_out.haml | 2 +- .../test/integration/global_edition_test.rb | 7 +++++-- 10 files changed, 36 insertions(+), 17 deletions(-) diff --git a/apps/src/signIn/SignInForm.tsx b/apps/src/signIn/SignInForm.tsx index 5a6724cbdf953..e511df9e8ca3b 100644 --- a/apps/src/signIn/SignInForm.tsx +++ b/apps/src/signIn/SignInForm.tsx @@ -10,9 +10,8 @@ import {USER_RETURN_TO_SESSION_KEY} from '@cdo/apps/signUpFlow/signUpFlowConstan import style from './signInStyles.module.scss'; -// Devise session endpoint. The field names below (user[login], -// user[password], user[hashed_email]) match what the Rails controller expects. -const SIGN_IN_PATH = '/users/sign_in'; +// The field names below (user[login], user[password], user[hashed_email]) +// match what the Rails controller expects. // These ids double as the DOM hooks the UI (Cucumber) tests drive: // #signin (wrapper), #user_login, #user_password, #signin-button. @@ -25,6 +24,9 @@ export interface SignInFormProps { hashedEmail: string; // Pre-populated login value (from @email), if any. loginValue: string; + // Form action from the server (session_path) so regional/localized sign-in + // routes (e.g. /fa/users/sign_in) are preserved. + signInPath: string; loginLabel: string; passwordLabel: string; signInLabel: string; @@ -42,6 +44,7 @@ export interface SignInFormProps { const SignInForm: React.FunctionComponent = ({ hashedEmail, loginValue, + signInPath, loginLabel, passwordLabel, signInLabel, @@ -74,7 +77,12 @@ const SignInForm: React.FunctionComponent = ({ return (
- + @@ -98,7 +106,7 @@ const SignInForm: React.FunctionComponent = ({ autoComplete="current-password" /> - {forgotPasswordPath && ( + {forgotPasswordPath && forgotPasswordLabel && ( { { screen.getByRole('button', {name: DEFAULT_PROPS.signInLabel}); }); - it('posts to /users/sign_in', () => { - renderForm(); + it('posts to signInPath as form#new_user (Rails/global-edition hooks)', () => { + renderForm({signInPath: '/fa/users/sign_in'}); const form = screen .getByRole('button', {name: DEFAULT_PROPS.signInLabel}) .closest('form'); - expect(form).toHaveAttribute('action', '/users/sign_in'); + expect(form).toHaveAttribute('action', '/fa/users/sign_in'); expect(form).toHaveAttribute('method', 'post'); + expect(form).toHaveAttribute('id', 'new_user'); }); it('threads the hashed_email through as a hidden field, unchanged', () => { diff --git a/dashboard/app/assets/stylesheets/application.scss b/dashboard/app/assets/stylesheets/application.scss index 63c828a0993de..98035d88c8ec4 100644 --- a/dashboard/app/assets/stylesheets/application.scss +++ b/dashboard/app/assets/stylesheets/application.scss @@ -1878,9 +1878,13 @@ a.download-video { max-width: 600px; } -// Sign-in styles retired: the sign-in form now lives in React -// (apps/src/signIn/SignInForm.tsx) with its own module.scss. These rules are -// kept only for #change-password (the password-reset page). +// The React sign-in page (apps/src/signIn/) styles itself, so #signin no longer +// carries these rules. They remain for #change-password (password reset) and +// for the legacy server-rendered sign-in/sign-up pages still using this markup +// (existing_account, PD/Foorm logged-out pages), which opt in via .signin-legacy. +// Don't re-add #signin here: the React page reuses that id as a test hook and +// must not inherit the legacy form/button styling. +.signin-legacy, #change-password { $field-width: 310px; $button-width: 324px; diff --git a/dashboard/app/views/devise/registrations/existing_account.html.haml b/dashboard/app/views/devise/registrations/existing_account.html.haml index 0e16a84c687b0..050587431a475 100644 --- a/dashboard/app/views/devise/registrations/existing_account.html.haml +++ b/dashboard/app/views/devise/registrations/existing_account.html.haml @@ -1,7 +1,7 @@ %h1 = I18n.t('auth.existing_account.account_exists', email: params[:email]) -#signin +#signin.signin-legacy %p = I18n.t('auth.existing_account.sign_in', provider: I18n.t(params[:provider], scope: "auth")) diff --git a/dashboard/app/views/devise/sessions/_login.html.haml b/dashboard/app/views/devise/sessions/_login.html.haml index c84728aa566c9..e392cbbd79325 100644 --- a/dashboard/app/views/devise/sessions/_login.html.haml +++ b/dashboard/app/views/devise/sessions/_login.html.haml @@ -17,6 +17,7 @@ data: { hashed_email: resource.hashed_email, login_value: (@email || ''), + sign_in_path: session_path(resource_name), login_label: t('signin_form.login_username'), password_label: resource.class.human_attribute_name(:password), sign_in_label: t('signin_form.submit'), diff --git a/dashboard/app/views/foorm/simple_survey_forms/logged_out.haml b/dashboard/app/views/foorm/simple_survey_forms/logged_out.haml index 2211f0ecc942d..3081f1702b6c5 100644 --- a/dashboard/app/views/foorm/simple_survey_forms/logged_out.haml +++ b/dashboard/app/views/foorm/simple_survey_forms/logged_out.haml @@ -8,7 +8,7 @@ .paragraph Please sign in or create an account: - #signin + #signin.signin-legacy = link_to "/users/sign_in?user_return_to=#{request.fullpath}", class: "paragraph" do %button= I18n.t('nav.user.signin') diff --git a/dashboard/app/views/pd/application/teacher_application/logged_out.haml b/dashboard/app/views/pd/application/teacher_application/logged_out.haml index 572465fde664b..698f4a5b4b7cd 100644 --- a/dashboard/app/views/pd/application/teacher_application/logged_out.haml +++ b/dashboard/app/views/pd/application/teacher_application/logged_out.haml @@ -24,7 +24,7 @@ .paragraph Please sign in or create an account: - #signin + #signin.signin-legacy = link_to "/users/sign_in?user_return_to=#{request.fullpath}", class: "paragraph" do %button= I18n.t('nav.user.signin') diff --git a/dashboard/app/views/pd/misc_survey/logged_out.haml b/dashboard/app/views/pd/misc_survey/logged_out.haml index dbe442f0d1785..73448e950dccb 100644 --- a/dashboard/app/views/pd/misc_survey/logged_out.haml +++ b/dashboard/app/views/pd/misc_survey/logged_out.haml @@ -11,7 +11,7 @@ .paragraph Please sign in or create an account: - #signin + #signin.signin-legacy = link_to "/users/sign_in?user_return_to=#{request.fullpath}", class: "paragraph" do %button= I18n.t('nav.user.signin') diff --git a/dashboard/test/integration/global_edition_test.rb b/dashboard/test/integration/global_edition_test.rb index 59bb73620e37c..46438a150ff7d 100644 --- a/dashboard/test/integration/global_edition_test.rb +++ b/dashboard/test/integration/global_edition_test.rb @@ -141,8 +141,11 @@ class GlobalEditionTest < ActionDispatch::IntegrationTest it 'routing helpers generates region version of urls' do get_regional_page - new_user_button = must_select("form#new_user[method='post']").first - _(new_user_button['action']).must_equal regional_page_path + # The sign-in form is React-rendered now, so the region-versioned action + # (session_path) is handed to the mount point as a data attribute rather + # than a server-rendered form[action]. + sign_in_mount = must_select('#sign-in-page-layout').first + _(sign_in_mount['data-sign-in-path']).must_equal regional_page_path end context 'for signed-in user' do From 8cd72c6ab37d3683286ae7735f50c7a954017de8 Mon Sep 17 00:00:00 2001 From: denyslevada Date: Fri, 3 Jul 2026 10:43:47 +0300 Subject: [PATCH 7/7] fix(sign-in): drop id="new_user" so login submits (was re-triggering legacy hash-on-submit) devise/sessions/new.js attaches a submit handler to form#new_user that calls window.dashboard.hashEmail against #user_hashed_email. The React sign-in form has no such element, so once the form carried id="new_user" that handler broke the native POST and login never reached /home (platform/signing_in and login_redirect UI tests failed). The id was only added to satisfy an old assertion in global_edition_test, which now checks the mount's data-sign-in-path instead, so the id is unnecessary. Remove it. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/src/signIn/SignInForm.tsx | 14 ++++++++------ apps/test/unit/signIn/SignInFormTest.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/src/signIn/SignInForm.tsx b/apps/src/signIn/SignInForm.tsx index e511df9e8ca3b..431712a87ad31 100644 --- a/apps/src/signIn/SignInForm.tsx +++ b/apps/src/signIn/SignInForm.tsx @@ -77,12 +77,14 @@ const SignInForm: React.FunctionComponent = ({ return (
- + {/* + Do NOT set id="new_user" on this form. devise/sessions/new.js hooks + form#new_user's submit to run window.dashboard.hashEmail against + #user_hashed_email (which this React form doesn't have), which breaks + the native POST and login never completes. The region-versioned action + is verified server-side via the mount's data-sign-in-path instead. + */} + diff --git a/apps/test/unit/signIn/SignInFormTest.tsx b/apps/test/unit/signIn/SignInFormTest.tsx index bbe02e495cec3..ad773c7f1e0a9 100644 --- a/apps/test/unit/signIn/SignInFormTest.tsx +++ b/apps/test/unit/signIn/SignInFormTest.tsx @@ -53,14 +53,16 @@ describe('SignInForm', () => { screen.getByRole('button', {name: DEFAULT_PROPS.signInLabel}); }); - it('posts to signInPath as form#new_user (Rails/global-edition hooks)', () => { + it('posts to signInPath (preserves the regional/localized action)', () => { renderForm({signInPath: '/fa/users/sign_in'}); const form = screen .getByRole('button', {name: DEFAULT_PROPS.signInLabel}) .closest('form'); expect(form).toHaveAttribute('action', '/fa/users/sign_in'); expect(form).toHaveAttribute('method', 'post'); - expect(form).toHaveAttribute('id', 'new_user'); + // Must NOT be id="new_user": devise/sessions/new.js hooks that form's + // submit to hash the email, which breaks this React-native POST. + expect(form).not.toHaveAttribute('id', 'new_user'); }); it('threads the hashed_email through as a hidden field, unchanged', () => {