feat: Add Login UI MFA Flow#142
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-142/ This page is automatically updated on each push to this PR. |
33d34df to
fc5deae
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-142/ This page is automatically updated on each push to this PR. |
fc5deae to
3481ac9
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-142/ This page is automatically updated on each push to this PR. |
There was a problem hiding this comment.
Actionable comments posted: 16
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@resources/js/base_actions.js`:
- Around line 102-106: The issue is that params are unconditionally being added
to the URL query string via the url.query(params) call, which exposes sensitive
POST data like passwords, OTPs, and captchas in URLs and logs. Modify the code
to only add params to the query string for non-POST requests. Check the HTTP
method of the request before calling url.query(params) and only apply it when
the method is not POST, ensuring sensitive data in POST requests stays in the
request body and is never exposed in the URL.
In `@resources/js/login/components/email_error_actions.js`:
- Around line 22-42: The two Button components in this block are not respecting
the disableInput state, allowing users to trigger actions even when the UI
should be locked. Add the disabled prop set to disableInput on both buttons: the
button with onClick={emitOtpAction} and the button with
href={createAccountAction}. This will prevent users from clicking these recovery
action buttons when the input is disabled.
In `@resources/js/login/components/email_input_form.js`:
- Around line 51-55: Remove the use of dangerouslySetInnerHTML on the error
message element in the email error rendering section. Instead of using
dangerouslySetInnerHTML with the emailError property, render the emailError text
directly as a child of the paragraph element to prevent potential XSS
vulnerabilities. This applies to the conditional block where emailError is not
empty and the error paragraph is being displayed.
In `@resources/js/login/components/existing_account_actions.js`:
- Around line 34-37: The `Link` component with `disabled={disableInput}` prop
does not actually prevent navigation in MUI v4 when using the default anchor
element. To fix this, add an onClick handler to the Link component that prevents
the default navigation behavior when disableInput is true. The onClick handler
should call preventDefault() on the event whenever disableInput is true,
effectively blocking the navigation while the input is locked.
In `@resources/js/login/components/help_links.js`:
- Around line 18-21: The URL construction for forgotPasswordActionHref does not
account for existing query parameters in the forgotPasswordAction base URL. When
appending the email parameter, the code always uses `?email=...`, which will
create invalid URLs if forgotPasswordAction already contains a query string.
Modify the conditional block where forgotPasswordActionHref is assigned (when
userName exists) to check if forgotPasswordAction already contains a `?`
character. If it does, use `&` to append the email parameter; otherwise use `?`.
This ensures proper URL formatting regardless of whether the base URL already
has query parameters.
In `@resources/js/login/components/otp_input_form.js`:
- Around line 55-58: The error label paragraph element in the OTP input form is
using dangerouslySetInnerHTML to render the otpError variable, which creates an
XSS security vulnerability. Remove the dangerouslySetInnerHTML prop and instead
render the otpError directly as text content of the paragraph element so that
any HTML-like characters in the error message are displayed as plain text rather
than being interpreted as executable HTML.
- Line 1: Remove the useMemo hook to prevent stale captcha visibility state.
First, remove useMemo from the import statement at the top of the
otp_input_form.js file. Then, locate where useMemo is being used in the
component (likely wrapping captcha visibility logic) and unwrap it by removing
the useMemo function call while keeping the code it was wrapping. This ensures
the captcha visibility logic runs on every render and properly reflects the
current state of dependencies like loginAttempts, rather than relying on a stale
memoized result based only on the function reference.
- Line 49: The `hasErrored` prop being passed to the OtpInput component is not
supported in react-otp-input v3.1.1 and was removed in v3.0.0+. Remove the
`hasErrored={!otpError}` prop from the OtpInput component and instead use the
`renderInput` prop to handle error state styling. Pass the otpError state to the
custom input component through the `renderInput` prop so that the error styling
can be conditionally applied within the input component based on the parent
component's otpError state.
In `@resources/js/login/components/password_input_form.js`:
- Around line 85-88: The paragraph element with className styles.error_label is
rendering passwordError as HTML using dangerouslySetInnerHTML, which creates an
XSS vulnerability. Remove the dangerouslySetInnerHTML prop and instead render
passwordError directly as text content within the paragraph element to safely
display the error message without executing any embedded HTML or scripts.
In `@resources/js/login/components/recovery_code_form.js`:
- Around line 22-25: The handleBack function defined in the recovery_code_form
component is not connected to any UI element, so users cannot interact with it
to go back to the OTP screen. Add a button element in the form's JSX render that
calls the handleBack function on click. This button should be placed in a
logical location in the recovery form UI, likely near the form submission or as
a navigation element. Note that the same issue applies to the code around lines
65-72, so ensure both instances have the corresponding UI elements added to
enable the back navigation functionality.
- Around line 53-55: The recoveryError is being rendered as raw HTML using
dangerouslySetInnerHTML, which creates an XSS vulnerability if the error content
contains user-controlled or backend text. Replace the dangerouslySetInnerHTML
prop with direct text rendering by removing the
dangerouslySetInnerHTML={{__html: recoveryError}} attribute and instead render
recoveryError directly as plain text inside the paragraph element with the
error_label className. This will automatically escape any HTML special
characters and prevent potential XSS attacks.
In `@resources/js/login/components/two_factor_form.js`:
- Around line 91-93: The otpError variable is being rendered with
dangerouslySetInnerHTML which creates an XSS vulnerability since the value comes
from server responses. Remove the dangerouslySetInnerHTML attribute from the
error message paragraph element in the two_factor_form component and instead
render otpError as plain text content. If HTML formatting is required for the
error message, sanitize otpError using a proper HTML sanitization library before
using dangerouslySetInnerHTML.
In `@resources/js/login/login.js`:
- Around line 435-463: Add an early guard clause at the beginning of the
onVerify2FA method to prevent duplicate verification requests. Before processing
any validation logic or making the verify2FA API call, check if
this.state.disableInput is already true, and if so, return early from the
method. This will prevent rapid successive clicks or Enter key events from
triggering multiple concurrent API calls. Apply the same guard pattern to the
other verify handler mentioned in the comment (around lines 524-547).
- Around line 183-202: In the handleAuthenticateValidation method, the password
validation block that checks if user_password is empty is being executed
unconditionally for all auth flows. This causes the OTP flow validation to fail
even when a valid OTP code is provided, because the password check comes after
the OTP check and always requires a password. Wrap the password validation block
in a conditional statement that only executes when the auth flow is NOT
FLOW.OTP, so that OTP flow validation can complete successfully without
requiring a password field.
- Around line 220-241: The `default` case in the switch statement declares
`const redirect` without block scope, creating scope and TDZ issues. Wrap the
entire contents of the `default` case (from the `const redirect` declaration
through the closing of the if-else block that checks the redirect condition)
with curly braces to create proper block scope and isolate the variable
declaration from other switch cases.
In `@resources/views/auth/login.blade.php`:
- Around line 102-107: The line setting window.RESET_2FA_ENDPOINT is referencing
config.reset2faAction which is not defined in the template's configuration
object, causing the window variable to become undefined. Verify the template's
PHP configuration section where the config object is initialized and either add
the missing reset2faAction property with the appropriate endpoint URL, or
correct the reference to use the correct existing config key name that
corresponds to the reset 2FA endpoint.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: dd2029b0-3aab-49c1-b3e1-2abdc066795e
📒 Files selected for processing (17)
package.jsonresources/js/base_actions.jsresources/js/login/actions.jsresources/js/login/components/email_error_actions.jsresources/js/login/components/email_input_form.jsresources/js/login/components/existing_account_actions.jsresources/js/login/components/help_links.jsresources/js/login/components/otp_help_links.jsresources/js/login/components/otp_input_form.jsresources/js/login/components/password_input_form.jsresources/js/login/components/recovery_code_form.jsresources/js/login/components/third_party_identity_providers.jsresources/js/login/components/two_factor_form.jsresources/js/login/constants.jsresources/js/login/login.jsresources/js/login/login.module.scssresources/views/auth/login.blade.php
| if (!isObjectEmpty(params)) | ||
| url = url.query(params); | ||
|
|
||
| let key = url.toString(); | ||
|
|
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Avoid putting POST payloads into the URL query string.
At Line 102-103, params are copied into url.query(...) before POST. With authenticateWithPassword in resources/js/login/actions.js (Line 60-62), this can expose password/OTP/captcha values via URL/query logging and request-key tracking. Keep MFA/login payloads in the request body only.
🔧 Proposed fix
export const postRawRequestFull = (endpoint) => (params, headers = {}) => {
- let url = URI(endpoint);
-
- if (!isObjectEmpty(params))
- url = url.query(params);
-
- let key = url.toString();
+ const url = URI(endpoint);
+ const key = url.toString();
cancel(key);
let req = http.post(url.toString());🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@resources/js/base_actions.js` around lines 102 - 106, The issue is that
params are unconditionally being added to the URL query string via the
url.query(params) call, which exposes sensitive POST data like passwords, OTPs,
and captchas in URLs and logs. Modify the code to only add params to the query
string for non-POST requests. Check the HTTP method of the request before
calling url.query(params) and only apply it when the method is not POST,
ensuring sensitive data in POST requests stays in the request body and is never
exposed in the URL.
| <Button | ||
| variant="contained" | ||
| onClick={emitOtpAction} | ||
| type="button" | ||
| className={styles.secondary_btn} | ||
| color="primary" | ||
| > | ||
| Email me a one time use code | ||
| </Button> | ||
| </Grid> | ||
| <Grid item> | ||
| <Button | ||
| variant="contained" | ||
| href={createAccountAction} | ||
| type="button" | ||
| target="_self" | ||
| className={styles.secondary_btn} | ||
| color="primary" | ||
| > | ||
| Register and set a password | ||
| </Button> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Disable all recovery actions while input is locked.
Two buttons in this block ignore disableInput, so users can still trigger actions while the UI is in a disabled state.
Suggested fix
<Grid item>
<Button
variant="contained"
onClick={emitOtpAction}
+ disabled={disableInput}
type="button"
className={styles.secondary_btn}
color="primary"
>
Email me a one time use code
</Button>
</Grid>
<Grid item>
<Button
variant="contained"
href={createAccountAction}
+ disabled={disableInput}
type="button"
target="_self"
className={styles.secondary_btn}
color="primary"
>
Register and set a password
</Button>
</Grid>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <Button | |
| variant="contained" | |
| onClick={emitOtpAction} | |
| type="button" | |
| className={styles.secondary_btn} | |
| color="primary" | |
| > | |
| Email me a one time use code | |
| </Button> | |
| </Grid> | |
| <Grid item> | |
| <Button | |
| variant="contained" | |
| href={createAccountAction} | |
| type="button" | |
| target="_self" | |
| className={styles.secondary_btn} | |
| color="primary" | |
| > | |
| Register and set a password | |
| </Button> | |
| <Button | |
| variant="contained" | |
| onClick={emitOtpAction} | |
| disabled={disableInput} | |
| type="button" | |
| className={styles.secondary_btn} | |
| color="primary" | |
| > | |
| Email me a one time use code | |
| </Button> | |
| </Grid> | |
| <Grid item> | |
| <Button | |
| variant="contained" | |
| href={createAccountAction} | |
| disabled={disableInput} | |
| type="button" | |
| target="_self" | |
| className={styles.secondary_btn} | |
| color="primary" | |
| > | |
| Register and set a password | |
| </Button> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@resources/js/login/components/email_error_actions.js` around lines 22 - 42,
The two Button components in this block are not respecting the disableInput
state, allowing users to trigger actions even when the UI should be locked. Add
the disabled prop set to disableInput on both buttons: the button with
onClick={emitOtpAction} and the button with href={createAccountAction}. This
will prevent users from clicking these recovery action buttons when the input is
disabled.
| {emailError != "" && ( | ||
| <p | ||
| className={styles.error_label} | ||
| dangerouslySetInnerHTML={{ __html: emailError }} | ||
| ></p> |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Avoid rendering raw HTML for email errors.
dangerouslySetInnerHTML on error content can introduce XSS if any part of the message is attacker-influenced.
Suggested fix
{emailError != "" && (
- <p
- className={styles.error_label}
- dangerouslySetInnerHTML={{ __html: emailError }}
- ></p>
+ <p className={styles.error_label}>{emailError}</p>
)}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {emailError != "" && ( | |
| <p | |
| className={styles.error_label} | |
| dangerouslySetInnerHTML={{ __html: emailError }} | |
| ></p> | |
| {emailError != "" && ( | |
| <p className={styles.error_label}>{emailError}</p> | |
| )} |
🧰 Tools
🪛 ast-grep (0.44.0)
[warning] 53-53: Do not assign unsanitized HTML to dangerouslySetInnerHTML; wrap with a sanitizer (e.g. DOMPurify.sanitize)
Context: dangerouslySetInnerHTML={{ __html: emailError }}
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
(react-dangerously-inner-html)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@resources/js/login/components/email_input_form.js` around lines 51 - 55,
Remove the use of dangerouslySetInnerHTML on the error message element in the
email error rendering section. Instead of using dangerouslySetInnerHTML with the
emailError property, render the emailError text directly as a child of the
paragraph element to prevent potential XSS vulnerabilities. This applies to the
conditional block where emailError is not empty and the error paragraph is being
displayed.
Source: Linters/SAST tools
| <Link | ||
| disabled={disableInput} | ||
| href={forgotPasswordActionHref} | ||
| target="_self" |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor
🧩 Analysis chain
🏁 Script executed:
# First, let's find and check the package.json to determine MUI version
cd /
fd -t f "package.json" -x head -50 {} \; 2>/dev/null | head -100Repository: OpenStackweb/openstackid
Length of output: 2974
🏁 Script executed:
# Find the specific file mentioned in the review
fd -t f "existing_account_actions.js"Repository: OpenStackweb/openstackid
Length of output: 220
🏁 Script executed:
# Check for package.json at repo root
cat package.json 2>/dev/null | grep -A5 -B5 "`@material-ui`"Repository: OpenStackweb/openstackid
Length of output: 698
🏁 Script executed:
# Read the existing_account_actions.js file to see the full context
cat -n resources/js/login/components/existing_account_actions.jsRepository: OpenStackweb/openstackid
Length of output: 1678
🏁 Script executed:
# Search for other usages of Link component in the codebase to understand pattern
rg "from.*`@material-ui/core`" resources/js/login/components/existing_account_actions.js -A 10Repository: OpenStackweb/openstackid
Length of output: 506
🏁 Script executed:
# Check what disableInput is and how it's defined
rg "disableInput" resources/js/login/components/existing_account_actions.js -B 2 -A 2Repository: OpenStackweb/openstackid
Length of output: 585
🌐 Web query:
Material-UI v4 Link component disabled prop behavior
💡 Result:
In Material-UI (MUI) v4, the Link component does not have a built-in "disabled" state or default styling for a disabled appearance [1][2]. While you can pass a disabled prop to the Link component—as it is built on top of the Typography component and supports spreading arbitrary props—the Link component itself does not natively interpret or style this prop [3][1][4]. Consequently, simply adding the disabled prop to a Link will not prevent user interaction, nor will it change the component's appearance [1][2]. To achieve a disabled effect in MUI v4, you must implement custom logic and styling [1]: 1. Accessibility and Interaction: If you need to stop interaction, you should manually disable the link's functionality (e.g., by preventing the onClick handler) and manage its focusability, such as by setting tabIndex={-1} [1]. For accessibility, it is recommended to add the aria-disabled="true" attribute [1]. 2. Styling: Since there is no default "disabled" class, you need to apply custom CSS to visually indicate that the link is disabled (e.g., changing the text color, setting cursor to default or not-allowed, and disabling text-decoration) [1]. 3. Using component="button": If you change the underlying element to a button using component="button", the standard HTML disabled attribute will function; however, you will still need to define custom styles to achieve the desired disabled visual appearance, as MUI v4 does not provide default disabled styling for the Link component even when rendered as a button [1][2].
Citations:
- 1: https://stackoverflow.com/questions/67421327/how-to-disable-the-material-ui-link-api-component
- 2: Link Component disabled property does not work correctly mui/material-ui#20013
- 3: https://v4.mui.com/api/link/
- 4: https://github.com/mui/material-ui/blob/v4.11.4/packages/material-ui/src/Link/Link.js
🌐 Web query:
MUI v4.11 Link API disabled attribute navigation
💡 Result:
In MUI v4.11, setting the disabled prop on MuiLink only prevents navigation when the rendered root element is a button (component="button"); otherwise it won’t reliably stop link navigation because the underlying element is an . What the code shows (v4.11.4): - MuiLink defaults component="a" (anchor). [1] - The Link implementation applies a “button” class only when component === 'button'. [1] So for disabled navigation you must do one of these: 1) Render it as a button and use disabled Use component="button". This makes disabled behave like a real disabled button: the browser won’t trigger navigation for href-based routing and MUI applies disabled semantics/styles for the button variant. - Example pattern: ... [2] 2) Keep it as an anchor () and block navigation yourself If you must use href with the default component="a", disabled does not inherently stop the default anchor click. A workaround is to intercept the click and call preventDefault when you want “disabled navigation”. (This is essentially the pattern described as a working workaround.) [2] 3) For accessibility If you implement “disabled-like” behavior via preventDefault, also add aria-disabled and remove from tab order as needed (as suggested in the workaround). [2] Practical takeaway - If you’re using MuiLink for navigation and want a true “disabled” state in MUI v4.11: set component="button" (and provide onClick only if needed) rather than relying on disabled while keeping it as an . [2][1] - If you keep href and the element: you need an onClick handler to preventDefault; disabled alone won’t be sufficient. [2]
Citations:
- 1: https://github.com/mui/material-ui/blob/v4.11.4/packages/material-ui/src/Link/Link.js
- 2: https://stackoverflow.com/questions/67421327/how-to-disable-the-material-ui-link-api-component
Link is not actually disabled here.
Passing disabled to MUI v4 Link does not prevent navigation when using the default component="a", so this action can still fire when input is locked.
Suggested fix
<Grid item xs={12}>
<Link
- disabled={disableInput}
href={forgotPasswordActionHref}
+ onClick={disableInput ? (ev) => ev.preventDefault() : undefined}
+ aria-disabled={disableInput}
+ className={disableInput ? styles.disabled_link : undefined}
target="_self"
variant="body2"
>
Reset your password
</Link>
</Grid>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@resources/js/login/components/existing_account_actions.js` around lines 34 -
37, The `Link` component with `disabled={disableInput}` prop does not actually
prevent navigation in MUI v4 when using the default anchor element. To fix this,
add an onClick handler to the Link component that prevents the default
navigation behavior when disableInput is true. The onClick handler should call
preventDefault() on the event whenever disableInput is true, effectively
blocking the navigation while the input is locked.
| let forgotPasswordActionHref = forgotPasswordAction; | ||
| if (userName) { | ||
| forgotPasswordActionHref = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Handle existing query params when appending email.
Current construction always uses ?email=..., which breaks when forgotPasswordAction already contains query parameters.
Suggested fix
let forgotPasswordActionHref = forgotPasswordAction;
if (userName) {
- forgotPasswordActionHref = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`;
+ const separator = forgotPasswordAction.includes("?") ? "&" : "?";
+ forgotPasswordActionHref = `${forgotPasswordAction}${separator}email=${encodeURIComponent(userName)}`;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let forgotPasswordActionHref = forgotPasswordAction; | |
| if (userName) { | |
| forgotPasswordActionHref = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; | |
| } | |
| let forgotPasswordActionHref = forgotPasswordAction; | |
| if (userName) { | |
| const separator = forgotPasswordAction.includes("?") ? "&" : "?"; | |
| forgotPasswordActionHref = `${forgotPasswordAction}${separator}email=${encodeURIComponent(userName)}`; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@resources/js/login/components/help_links.js` around lines 18 - 21, The URL
construction for forgotPasswordActionHref does not account for existing query
parameters in the forgotPasswordAction base URL. When appending the email
parameter, the code always uses `?email=...`, which will create invalid URLs if
forgotPasswordAction already contains a query string. Modify the conditional
block where forgotPasswordActionHref is assigned (when userName exists) to check
if forgotPasswordAction already contains a `?` character. If it does, use `&` to
append the email parameter; otherwise use `?`. This ensures proper URL
formatting regardless of whether the base URL already has query parameters.
| {otpError && | ||
| <p className={styles.error_label} dangerouslySetInnerHTML={{__html: otpError}}></p> | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Sanitize otpError before injecting HTML.
otpError is rendered through dangerouslySetInnerHTML, and this value can come from server response text. That creates an XSS sink on the login surface.
🔧 Suggested fix
- {otpError &&
- <p className={styles.error_label} dangerouslySetInnerHTML={{__html: otpError}}></p>
- }
+ {otpError && <p className={styles.error_label}>{otpError}</p>}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {otpError && | |
| <p className={styles.error_label} dangerouslySetInnerHTML={{__html: otpError}}></p> | |
| } | |
| {otpError && <p className={styles.error_label}>{otpError}</p>} |
🧰 Tools
🪛 ast-grep (0.44.0)
[warning] 91-91: Do not assign unsanitized HTML to dangerouslySetInnerHTML; wrap with a sanitizer (e.g. DOMPurify.sanitize)
Context: dangerouslySetInnerHTML={{__html: otpError}}
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
(react-dangerously-inner-html)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@resources/js/login/components/two_factor_form.js` around lines 91 - 93, The
otpError variable is being rendered with dangerouslySetInnerHTML which creates
an XSS vulnerability since the value comes from server responses. Remove the
dangerouslySetInnerHTML attribute from the error message paragraph element in
the two_factor_form component and instead render otpError as plain text content.
If HTML formatting is required for the error message, sanitize otpError using a
proper HTML sanitization library before using dangerouslySetInnerHTML.
Source: Linters/SAST tools
| handleAuthenticateValidation() { | ||
| if (this.state.authFlow === FLOW.OTP) { | ||
| if (this.state.otpCode == "") { | ||
| this.setState({ | ||
| ...this.state, | ||
| disableInput: false, | ||
| errors: { ...this.state.errors, otp: "Single-use code is empty" }, | ||
| }); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <Grid container spacing={1} style={{marginTop: "30px"}}> | ||
| <Grid item xs={12}> | ||
| <Button variant="contained" | ||
| onClick={emitOtpAction} | ||
| type="button" | ||
| disabled={disableInput} | ||
| className={styles.secondary_btn} | ||
| color="primary"> | ||
| Sign in by emailing me a single-use code | ||
| </Button> | ||
| </Grid> | ||
| <Grid item xs={12}> | ||
| <Link | ||
| disabled={disableInput} | ||
| href={forgotPasswordAction} target="_self" variant="body2"> | ||
| Reset your password | ||
| </Link> | ||
| </Grid> | ||
| </Grid> | ||
| ); | ||
| } | ||
|
|
||
| const ThirdPartyIdentityProviders = ({ thirdPartyProviders, formAction, disableInput, allowNativeAuth }) => { | ||
| return ( | ||
| <> | ||
| {allowNativeAuth && <DividerWithText>or</DividerWithText>} | ||
| { | ||
| thirdPartyProviders.map((provider) => { | ||
| const verbiage = `${handleThirdPartyProvidersVerbiage(provider.name)} with ${provider.label}`; | ||
| return ( | ||
| <Button | ||
| disabled={disableInput} | ||
| key={provider.name} | ||
| variant="contained" | ||
| className={styles.third_party_idp_button + ` ${provider.name}`} | ||
| color="primary" | ||
| target="_self" | ||
| title={verbiage} | ||
| href={`${formAction}/${provider.name}`}> | ||
| {verbiage} | ||
| </Button> | ||
| ); | ||
| }) | ||
| } | ||
| <p>If you have a login, you may still choose to use a social login with <b>the same email address</b> to | ||
| access your account.</p> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| const otp_flow = 'otp'; | ||
| const password_flow = 'password'; | ||
| if (this.state.user_password == "") { | ||
| this.setState({ | ||
| ...this.state, | ||
| disableInput: false, | ||
| errors: { ...this.state.errors, password: "Password is empty" }, | ||
| }); | ||
| return false; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
OTP flow is incorrectly blocked by password validation.
After OTP validation, the method still requires user_password for all flows. In FLOW.OTP, this rejects valid OTP submissions with “Password is empty”.
🔧 Suggested fix
- if (this.state.user_password == "") {
+ if (
+ this.state.authFlow === FLOW.PASSWORD &&
+ this.state.user_password === ""
+ ) {
this.setState({
...this.state,
disableInput: false,
errors: { ...this.state.errors, password: "Password is empty" },
});
return false;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| handleAuthenticateValidation() { | |
| if (this.state.authFlow === FLOW.OTP) { | |
| if (this.state.otpCode == "") { | |
| this.setState({ | |
| ...this.state, | |
| disableInput: false, | |
| errors: { ...this.state.errors, otp: "Single-use code is empty" }, | |
| }); | |
| return false; | |
| } | |
| } | |
| return ( | |
| <Grid container spacing={1} style={{marginTop: "30px"}}> | |
| <Grid item xs={12}> | |
| <Button variant="contained" | |
| onClick={emitOtpAction} | |
| type="button" | |
| disabled={disableInput} | |
| className={styles.secondary_btn} | |
| color="primary"> | |
| Sign in by emailing me a single-use code | |
| </Button> | |
| </Grid> | |
| <Grid item xs={12}> | |
| <Link | |
| disabled={disableInput} | |
| href={forgotPasswordAction} target="_self" variant="body2"> | |
| Reset your password | |
| </Link> | |
| </Grid> | |
| </Grid> | |
| ); | |
| } | |
| const ThirdPartyIdentityProviders = ({ thirdPartyProviders, formAction, disableInput, allowNativeAuth }) => { | |
| return ( | |
| <> | |
| {allowNativeAuth && <DividerWithText>or</DividerWithText>} | |
| { | |
| thirdPartyProviders.map((provider) => { | |
| const verbiage = `${handleThirdPartyProvidersVerbiage(provider.name)} with ${provider.label}`; | |
| return ( | |
| <Button | |
| disabled={disableInput} | |
| key={provider.name} | |
| variant="contained" | |
| className={styles.third_party_idp_button + ` ${provider.name}`} | |
| color="primary" | |
| target="_self" | |
| title={verbiage} | |
| href={`${formAction}/${provider.name}`}> | |
| {verbiage} | |
| </Button> | |
| ); | |
| }) | |
| } | |
| <p>If you have a login, you may still choose to use a social login with <b>the same email address</b> to | |
| access your account.</p> | |
| </> | |
| ); | |
| } | |
| const otp_flow = 'otp'; | |
| const password_flow = 'password'; | |
| if (this.state.user_password == "") { | |
| this.setState({ | |
| ...this.state, | |
| disableInput: false, | |
| errors: { ...this.state.errors, password: "Password is empty" }, | |
| }); | |
| return false; | |
| } | |
| handleAuthenticateValidation() { | |
| if (this.state.authFlow === FLOW.OTP) { | |
| if (this.state.otpCode == "") { | |
| this.setState({ | |
| ...this.state, | |
| disableInput: false, | |
| errors: { ...this.state.errors, otp: "Single-use code is empty" }, | |
| }); | |
| return false; | |
| } | |
| } | |
| if ( | |
| this.state.authFlow === FLOW.PASSWORD && | |
| this.state.user_password === "" | |
| ) { | |
| this.setState({ | |
| ...this.state, | |
| disableInput: false, | |
| errors: { ...this.state.errors, password: "Password is empty" }, | |
| }); | |
| return false; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@resources/js/login/login.js` around lines 183 - 202, In the
handleAuthenticateValidation method, the password validation block that checks
if user_password is empty is being executed unconditionally for all auth flows.
This causes the OTP flow validation to fail even when a valid OTP code is
provided, because the password check comes after the OTP check and always
requires a password. Wrap the password validation block in a conditional
statement that only executes when the auth flow is NOT FLOW.OTP, so that OTP
flow validation can complete successfully without requiring a password field.
| switch (error_code) { | ||
| case MFA_ERROR_CODE.MFA_CHALLENGE_REQUIRED: | ||
| this.setState((prevState) => ({ | ||
| ...prevState, | ||
| authFlow: FLOW.MFA, | ||
| disableInput: false, | ||
| otpLength: otp_length ?? prevState.otpLength, | ||
| otpLifetime: otp_lifetime ?? prevState.otpLifetime, | ||
| })); | ||
| break; | ||
| default: | ||
| const redirect = finalUrl && status === HTTP_CODES.OK; | ||
| if (redirect) { | ||
| window.location.href = finalUrl; | ||
| } else { | ||
| this.showAlert("Oops... Something went wrong!", "error"); | ||
| this.setState((prevState) => ({ | ||
| ...prevState, | ||
| disableInput: false, | ||
| authFlow: props.flow, | ||
| allowNativeAuth: props.allowNativeAuth, | ||
| showInfoBanner: props.showInfoBanner, | ||
| infoBannerContent: props.infoBannerContent, | ||
| } | ||
|
|
||
| if (props.authError != '' && !this.state.user_fullname) { | ||
| this.state.user_fullname = props.userName; | ||
| })); | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
# First, find the file and verify its existence
fd -t f login.js | head -20Repository: OpenStackweb/openstackid
Length of output: 216
🏁 Script executed:
# Check the specific lines in the file
if [ -f "resources/js/login/login.js" ]; then
echo "=== File found, checking lines 220-241 ==="
sed -n '220,241p' "resources/js/login/login.js"
else
echo "=== File not found at that location, searching ==="
find . -name "login.js" -type f 2>/dev/null | head -10
fiRepository: OpenStackweb/openstackid
Length of output: 914
🏁 Script executed:
# Get context around lines 220-241 to understand the function
if [ -f "resources/js/login/login.js" ]; then
echo "=== Extended context (lines 210-250) ==="
sed -n '210,250p' "resources/js/login/login.js"
fiRepository: OpenStackweb/openstackid
Length of output: 1488
🏁 Script executed:
# Check if Biome or ESLint is configured and what rules apply
fd -t f -e json -e js -e yaml \
| xargs grep -l "noSwitchDeclarations\|eslintrc\|biome.json" 2>/dev/null \
| head -10Repository: OpenStackweb/openstackid
Length of output: 162
🏁 Script executed:
# Check for linter configuration files
fd -t f \( -name "biome.json" -o -name ".eslintrc*" -o -name "eslint.config.*" \) | head -10Repository: OpenStackweb/openstackid
Length of output: 393
🏁 Script executed:
# Check package.json for eslint/biome dependencies
if [ -f "package.json" ]; then
echo "=== Checking for linter packages in package.json ==="
cat package.json | grep -A 5 -B 5 '"devDependencies"\|"eslint\|biome"' | head -30
fiRepository: OpenStackweb/openstackid
Length of output: 766
🏁 Script executed:
# Verify the JavaScript/React syntax is correct by examining the file structure
ast-grep outline resources/js/login/login.js --view expanded | grep -A 20 "handleAuthenticatePasswordOk"Repository: OpenStackweb/openstackid
Length of output: 869
🏁 Script executed:
# Search for all config files more comprehensively
find . -maxdepth 2 -type f \( -name "*eslint*" -o -name "biome.json" -o -name ".prettierrc*" \) 2>/dev/nullRepository: OpenStackweb/openstackid
Length of output: 162
🏁 Script executed:
# Check the entire package.json to see all dependencies
if [ -f "package.json" ]; then
wc -l package.json
cat package.json | jq '.devDependencies | keys[]' 2>/dev/null | grep -i "eslint\|biome\|lint" | head -20
fiRepository: OpenStackweb/openstackid
Length of output: 179
🏁 Script executed:
# Verify the code pattern is actually problematic by checking what rules would flag it
# This is a well-known pattern that both ESLint and Biome flag
# Let's also check if there are any other switch statements in the codebase to compare
rg "switch\s*\(" resources/js/login/login.js -A 15 | head -50Repository: OpenStackweb/openstackid
Length of output: 739
🏁 Script executed:
# Check for any CI/CD configuration that might enforce linting
find . -maxdepth 2 -type f \( -name ".github" -o -name ".gitlab-ci.yml" -o -name "*.yml" -o -name "*.yaml" \) | head -20Repository: OpenStackweb/openstackid
Length of output: 208
🏁 Script executed:
# Look for any GitHub Actions workflow files
find .github/workflows -type f 2>/dev/null | head -10Repository: OpenStackweb/openstackid
Length of output: 318
🏁 Script executed:
# Check if there's a .github directory with any workflow files
if [ -d ".github" ]; then
find .github -type f | head -20
fiRepository: OpenStackweb/openstackid
Length of output: 318
🏁 Script executed:
# Search the entire repo for any eslint or biome configuration files more thoroughly
find . -name "*eslint*" -o -name "biome.json" -o -name ".prettierrc*" 2>/dev/null | grep -v node_modulesRepository: OpenStackweb/openstackid
Length of output: 397
🏁 Script executed:
# Let's check the complete package.json for linting tools
cat package.json | jq '.devDependencies' 2>/dev/null | head -30Repository: OpenStackweb/openstackid
Length of output: 1161
🏁 Script executed:
# Check if there are any other similar patterns in switch statements in the codebase
rg "switch\s*\(" --type js -A 20 | rg "^\s*(case|default):" -A 3 | head -40Repository: OpenStackweb/openstackid
Length of output: 162
🏁 Script executed:
# Verify that the suggested fix (wrapping in braces) doesn't break anything
# by checking the structure of the fixed code
cat > /tmp/test-fix.js << 'EOF'
// Original code structure test
switch (error_code) {
case MFA_ERROR_CODE.MFA_CHALLENGE_REQUIRED:
this.setState((prevState) => ({
...prevState,
authFlow: FLOW.MFA,
disableInput: false,
otpLength: otp_length ?? prevState.otpLength,
otpLifetime: otp_lifetime ?? prevState.otpLifetime,
}));
break;
default: {
const redirect = finalUrl && status === HTTP_CODES.OK;
if (redirect) {
window.location.href = finalUrl;
} else {
this.showAlert("Oops... Something went wrong!", "error");
this.setState((prevState) => ({
...prevState,
disableInput: false,
}));
}
}
}
EOF
# Verify syntax is valid
node --check /tmp/test-fix.js && echo "✓ Suggested fix is syntactically valid"Repository: OpenStackweb/openstackid
Length of output: 201
Wrap the default switch clause in braces.
The default clause declares const redirect directly without block scope, creating scope and TDZ (Temporal Dead Zone) issues. Wrapping it in braces isolates the declaration and prevents potential bugs.
🔧 Suggested fix
case MFA_ERROR_CODE.MFA_CHALLENGE_REQUIRED:
this.setState((prevState) => ({
...prevState,
authFlow: FLOW.MFA,
disableInput: false,
otpLength: otp_length ?? prevState.otpLength,
otpLifetime: otp_lifetime ?? prevState.otpLifetime,
}));
break;
- default:
+ default: {
const redirect = finalUrl && status === HTTP_CODES.OK;
if (redirect) {
window.location.href = finalUrl;
} else {
this.showAlert("Oops... Something went wrong!", "error");
this.setState((prevState) => ({
...prevState,
disableInput: false,
}));
}
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| switch (error_code) { | |
| case MFA_ERROR_CODE.MFA_CHALLENGE_REQUIRED: | |
| this.setState((prevState) => ({ | |
| ...prevState, | |
| authFlow: FLOW.MFA, | |
| disableInput: false, | |
| otpLength: otp_length ?? prevState.otpLength, | |
| otpLifetime: otp_lifetime ?? prevState.otpLifetime, | |
| })); | |
| break; | |
| default: | |
| const redirect = finalUrl && status === HTTP_CODES.OK; | |
| if (redirect) { | |
| window.location.href = finalUrl; | |
| } else { | |
| this.showAlert("Oops... Something went wrong!", "error"); | |
| this.setState((prevState) => ({ | |
| ...prevState, | |
| disableInput: false, | |
| authFlow: props.flow, | |
| allowNativeAuth: props.allowNativeAuth, | |
| showInfoBanner: props.showInfoBanner, | |
| infoBannerContent: props.infoBannerContent, | |
| } | |
| if (props.authError != '' && !this.state.user_fullname) { | |
| this.state.user_fullname = props.userName; | |
| })); | |
| } | |
| } | |
| switch (error_code) { | |
| case MFA_ERROR_CODE.MFA_CHALLENGE_REQUIRED: | |
| this.setState((prevState) => ({ | |
| ...prevState, | |
| authFlow: FLOW.MFA, | |
| disableInput: false, | |
| otpLength: otp_length ?? prevState.otpLength, | |
| otpLifetime: otp_lifetime ?? prevState.otpLifetime, | |
| })); | |
| break; | |
| default: { | |
| const redirect = finalUrl && status === HTTP_CODES.OK; | |
| if (redirect) { | |
| window.location.href = finalUrl; | |
| } else { | |
| this.showAlert("Oops... Something went wrong!", "error"); | |
| this.setState((prevState) => ({ | |
| ...prevState, | |
| disableInput: false, | |
| })); | |
| } | |
| } | |
| } |
🧰 Tools
🪛 Biome (2.5.0)
[error] 231-231: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.
(lint/correctness/noSwitchDeclarations)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@resources/js/login/login.js` around lines 220 - 241, The `default` case in
the switch statement declares `const redirect` without block scope, creating
scope and TDZ issues. Wrap the entire contents of the `default` case (from the
`const redirect` declaration through the closing of the if-else block that
checks the redirect condition) with curly braces to create proper block scope
and isolate the variable declaration from other switch cases.
Source: Linters/SAST tools
| onVerify2FA() { | ||
| const { twoFactorCode, trustDevice, mfaMethod } = this.state; | ||
| if (twoFactorCode === "") { | ||
| this.setState({ | ||
| ...this.state, | ||
| errors: { | ||
| ...this.state.errors, | ||
| twofactor: "Verification code is empty", | ||
| }, | ||
| }); | ||
| return; | ||
| } | ||
| this.setState({ | ||
| ...this.state, | ||
| disableInput: true, | ||
| errors: { ...this.state.errors, twofactor: "" }, | ||
| }); | ||
|
|
||
| verify2FA(twoFactorCode, mfaMethod, trustDevice, this.props.token).then( | ||
| (payload) => { | ||
| // Success: the backend redirected (302) and the XHR followed it; navigate the top | ||
| // window to the final destination to resume the normal redirect / OIDC flow. | ||
| window.location.href = payload.finalUrl || window.location.href; | ||
| }, | ||
| (error) => { | ||
| this.handleMfaError(error, "twofactor"); | ||
| }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Add an early guard to prevent duplicate verify requests.
Both verify handlers can be invoked while disableInput is already true, which can trigger duplicate 2FA/recovery API calls (e.g., rapid submit/Enter events).
🔧 Suggested fix
onVerify2FA() {
+ if (this.state.disableInput) return;
const { twoFactorCode, trustDevice, mfaMethod } = this.state;
if (twoFactorCode === "") {
this.setState({
@@
onVerifyRecovery() {
+ if (this.state.disableInput) return;
const { recoveryCode } = this.state;
if (recoveryCode === "") {
this.setState({Also applies to: 524-547
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@resources/js/login/login.js` around lines 435 - 463, Add an early guard
clause at the beginning of the onVerify2FA method to prevent duplicate
verification requests. Before processing any validation logic or making the
verify2FA API call, check if this.state.disableInput is already true, and if so,
return early from the method. This will prevent rapid successive clicks or Enter
key events from triggering multiple concurrent API calls. Apply the same guard
pattern to the other verify handler mentioned in the comment (around lines
524-547).
| window.VERIFY_2FA_ENDPOINT = config.verify2faAction; | ||
| window.RESEND_2FA_ENDPOINT = config.resend2faAction; | ||
| window.CANCEL_LOGIN_ENDPOINT = config.cancelLogin; | ||
| window.RECOVERY_2FA_ENDPOINT = config.recovery2faAction; | ||
| window.RESET_2FA_ENDPOINT = config.reset2faAction; | ||
| window.FORM_ACTION_ENDPOINT = config.formAction; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
RESET_2FA_ENDPOINT is wired to an undefined config key.
config.reset2faAction is never defined in this template, so window.RESET_2FA_ENDPOINT becomes undefined.
🔧 Suggested fix
- window.RESET_2FA_ENDPOINT = config.reset2faAction;
+ window.RESET_2FA_ENDPOINT = config.cancelLogin;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| window.VERIFY_2FA_ENDPOINT = config.verify2faAction; | |
| window.RESEND_2FA_ENDPOINT = config.resend2faAction; | |
| window.CANCEL_LOGIN_ENDPOINT = config.cancelLogin; | |
| window.RECOVERY_2FA_ENDPOINT = config.recovery2faAction; | |
| window.RESET_2FA_ENDPOINT = config.reset2faAction; | |
| window.FORM_ACTION_ENDPOINT = config.formAction; | |
| window.VERIFY_2FA_ENDPOINT = config.verify2faAction; | |
| window.RESEND_2FA_ENDPOINT = config.resend2faAction; | |
| window.CANCEL_LOGIN_ENDPOINT = config.cancelLogin; | |
| window.RECOVERY_2FA_ENDPOINT = config.recovery2faAction; | |
| window.RESET_2FA_ENDPOINT = config.cancelLogin; | |
| window.FORM_ACTION_ENDPOINT = config.formAction; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@resources/views/auth/login.blade.php` around lines 102 - 107, The line
setting window.RESET_2FA_ENDPOINT is referencing config.reset2faAction which is
not defined in the template's configuration object, causing the window variable
to become undefined. Verify the template's PHP configuration section where the
config object is initialized and either add the missing reset2faAction property
with the appropriate endpoint URL, or correct the reference to use the correct
existing config key name that corresponds to the reset 2FA endpoint.
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-142/ This page is automatically updated on each push to this PR. |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-142/ This page is automatically updated on each push to this PR. |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-142/ This page is automatically updated on each push to this PR. |
Task:
Ref: https://app.clickup.com/t/9014802374/86ba2zp3q
Changes
Preview
Overview
Frontend-only change adding a multi-factor authentication (MFA) flow to the React login UI. The monolithic
login.jswas refactored — inline form components were extracted into dedicated files, and two new MFA screens (verification code + recovery code) were added.Highlights
mfa_required, which transitions the UI into a two-factor email-OTP challenge instead of completing login.postRawRequestFullhelper captures the final URL after server-side redirects (xhr.responseURL) so the SPA can navigate the top window to the post-login destination.login.jswere moved intoresources/js/login/components/, slimming and reorganizing the main file.New Files
Actions & constants
resources/js/login/constants.js—HTTP_CODES,LOGIN_FLOW,MFA_METHODS,FLOW,MFA_ERROR_CODE, and defaults (OTP length 6, TTL 300, default methodemail_otp).Login form components (
resources/js/login/components/)two_factor_form.js— MFA email-OTP entry with expiry countdown, 30s resend cooldown, "Trust this device for 30 days", links to recovery code / cancel.recovery_code_form.js— recovery code entry screen with back-to-OTP and cancel.email_input_form.js— extracted email entry form.password_input_form.js— extracted password form (incl. failed-attempt / account-lock messaging).otp_input_form.js— extracted single-use code form.email_error_actions.js,existing_account_actions.js,help_links.js,otp_help_links.js,third_party_identity_providers.js— extracted helper/link components.Modified Files
resources/js/base_actions.js— addedpostRawRequestFull(returns{ response, status, finalUrl }).resources/js/login/actions.js— addedverify2FA,resend2FA,verifyRecoveryCode, andauthenticateWithPassword(uses the redirect-aware helper).resources/js/login/login.js— major refactor:mfaMethod,trustDevice,twoFactorCode,recoveryCode,otpLength,otpLifetime,codeVersion) and handlers (onVerify2FA,onResend2FA,onVerifyRecovery,onCancel2FA,onUseRecovery,onBackToOtp).authenticateWithPassword; onmfa_requiredit switches to the MFA flow, otherwise redirects viafinalUrl.showTwoFactorForm,showRecoveryForm,isPasswordFlow,isOtpFlow,showDefaultFlow).resources/js/login/login.module.scss— new styles:.info_message,.countdown,.trust_device_row,.disabled_link.resources/views/auth/login.blade.php— wired new endpoints/config:verify2faAction,resend2faAction,recovery2faAction,mfaMethod, plus session-drivenotpLength/otpLifetime; exposesVERIFY_2FA_ENDPOINT,RESEND_2FA_ENDPOINT,RECOVERY_2FA_ENDPOINT,FORM_ACTION_ENDPOINTonwindow.package.json— updatedservescript flag (--https→--server-type https) for the newer webpack-dev-server.User's GOAL
Current state
The login React component supports password and passwordless OTP flows. It has no MFA verification screen after password authentication.
Target state
The login UI supports a third flow state, '2fa'. When the backend returns mfa_required, the UI renders an MFA verification step with OTP input, trust-device checkbox, resend action, recovery-code link, and relevant error handling.
TASKS
( each component should have its own file )
ACCEPTANCE CRITERIA
DEVELOPMENT NOTES
Key files:
Gotchas:
Risks:
Out of scope:
Recovery-code profile management, device-management UI, method selector for future MFA methods.
Summary by CodeRabbit
New Features
Bug Fixes
Chores