Skip to content

Port login page to preact #9589

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open

Port login page to preact #9589

wants to merge 1 commit into from

Conversation

mtomilov
Copy link
Contributor

@mtomilov mtomilov commented May 26, 2025

Refs #9588

Here we port login page to react in a hybrid way.
React app renders the form, but we actually submit it. And POST request is handled by the backend.
Login form controller is mostly based on group form controller.
For now it only affects regular login and open authorization popup login still uses Jinja2 for easier porting, which we'll address next.
Reusable components like text field will need to be lifted up from group form app.

Testing

  • Go to /login page
  • Try logging with valid and invalid credentials
  • Go to /login?for_oauth=true page
  • Try logging with valid and invalid credentials to make sure it continues to work as before

@robertknight
Copy link
Member

For the files which are re-used unchanged from the group forms app, I would suggest to simply re-import them from there for the moment. One of the frontend devs can then look at moving them to a shared directory and adjusting the tests etc.

@mtomilov
Copy link
Contributor Author

mtomilov commented May 27, 2025

For the files which are re-used unchanged from the group forms app, I would suggest to simply re-import them from there for the moment. One of the frontend devs can then look at moving them to a shared directory and adjusting the tests etc.

Thank you. I was going to lift them up to /scripts, but sure not problem.
Duplicated them for draft because I wanted to make sure that I can reuse them as is.

@mtomilov mtomilov force-pushed the port-login-page-to-preact branch 7 times, most recently from 10962d0 to da90554 Compare May 28, 2025 13:11
inputType?: 'text' | 'password';

/** Name of the input field. */
name?: string;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make some changes for text properties because we actually submit this React form, not make API calls from here.

@@ -60,6 +74,9 @@ export type TextFieldProps = {

/** Additional classes to apply to the input element. */
classes?: string;

/** Optional error message for the field. */
fieldError?: string;
Copy link
Contributor Author

@mtomilov mtomilov May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this is field validation error we get from backend login schema.
I've tried to make regular input error work but unfortunately it seems to be designed for in-place submit validation, not when the error is coming from the outside.

{fieldError && (
<>
<div className="grow" />
<ErrorNotice message={fieldError} />
Copy link
Contributor Author

@mtomilov mtomilov May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conditionally rendering the field error

classes={classes}
autofocus={autofocus}
autocomplete="off"
required={required}
{...(name ? { name: name } : {})}
{...(inputType === 'password' ? { type: 'password' } : {})}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works, but I'm not a fan. Maybe we actually need a separate text field for login/signup form, but the changes seem to be minor, so I'm not sure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can set these fields but use the value undefined and they won't appear in the HTML:

diff --git a/h/static/scripts/group-forms/components/forms/TextField.tsx b/h/static/scripts/group-forms/components/forms/TextField.tsx
index 0925b7177..bea45fcfe 100644
--- a/h/static/scripts/group-forms/components/forms/TextField.tsx
+++ b/h/static/scripts/group-forms/components/forms/TextField.tsx
@@ -78,7 +78,7 @@ export type TextFieldProps = {
  */
 export default function TextField({
   type = 'input',
-  inputType = 'text',
+  inputType,
   value,
   onChangeValue,
   minLength = 0,
@@ -87,7 +87,7 @@ export default function TextField({
   required = false,
   autofocus = false,
   classes = '',
-  name = '',
+  name,
   fieldError = '',
 }: TextFieldProps) {
   const id = useId();
@@ -131,8 +131,8 @@ export default function TextField({
         autofocus={autofocus}
         autocomplete="off"
         required={required}
-        {...(name ? { name: name } : {})}
-        {...(inputType === 'password' ? { type: 'password' } : {})}
+        name={name}
+        type={inputType}
       />
       {typeof maxLength === 'number' && (
         <CharacterCounter

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, thank you!

<Config.Provider value={config}>
<Router>
<Switch>
<Route path={routes.login}>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking to extend this with sign up route in the switch when we get to that rather than creating a new sign up react app.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think that will make sense.

data-testid="form"
className="max-w-[530px] mx-auto flex flex-col gap-y-4"
>
<input type="hidden" name="csrf_token" value={config.csrfToken} />
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting csrf token from js config on the form for pyramid.

@mtomilov mtomilov force-pushed the port-login-page-to-preact branch from da90554 to 64122dc Compare May 28, 2025 13:21
</footer>
</div>
</div>
{% endblock %}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mostly follows what we have in group jinja2 html template.

@view_config(
request_method="GET",
request_param="for_oauth",
renderer="h:templates/accounts/login_oauth.html.jinja2",
)
def get(self):
"""Render the login page, including the login form."""
def get_oauth(self):
Copy link
Contributor Author

@mtomilov mtomilov May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splitting these two routes for now to limit the scope of this PR.
So, regular login is covered by React app and open authorization login is covered by Jinja2 at this point.

csrf_token = get_csrf_token(self.request)

return {
"styles": self.request.registry["assets_env"].urls("group_forms_css"),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reusing group form styles because basically these are just tailwind styles, which is exactly the same we need for login form.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something to know about how Tailwind works is that it scans a specified set of files looking for Tailwind classes that are used and only generates those styles in the CSS. The set of files that are scanned includes all of our TSX files under h/static/scripts, so re-using the CSS here will work. It will probably make sense to rename this bundle to something more general.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, renamed to forms.css

except deform.ValidationFailure as e:
js_config = self._js_config()
js_config["formErrors"] = e.error.asdict()
js_config["formData"] = {
Copy link
Contributor Author

@mtomilov mtomilov May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to pass back both form errors and form data for pre-fill if backend POST validation fails.
Again, this is a separate route to take care of regular POST login for now.
I'm using camel case here because this data is prepared by backend but used by frontend.

res.form["username"] = user.username
res.form["password"] = "pass" # noqa: S105
res.form.submit()
js_config = json.loads(res.html.find("script", class_="js-config").text)
Copy link
Contributor Author

@mtomilov mtomilov May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since login form is now rendered by React, we need to make POST request here for login, taking csrf token from HTML page body.

@mtomilov mtomilov force-pushed the port-login-page-to-preact branch 4 times, most recently from 6d33dbe to fe871c4 Compare May 28, 2025 14:07
@mtomilov mtomilov marked this pull request as ready for review May 28, 2025 14:13
@mtomilov mtomilov requested a review from robertknight May 28, 2025 14:13
@mtomilov mtomilov force-pushed the port-login-page-to-preact branch from fe871c4 to cac877b Compare May 28, 2025 14:19
@robertknight
Copy link
Member

For the benefit of non-developers, the old login screen:

Old login

New login screen:

New login

Copy link
Member

@robertknight robertknight left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good overall from an initial glance. I think we should remove the visible "required" markers from the fields in this form, though we will need to keep the required attribute on the form field. This will require a small change to the TextField API.

classes={classes}
autofocus={autofocus}
autocomplete="off"
required={required}
{...(name ? { name: name } : {})}
{...(inputType === 'password' ? { type: 'password' } : {})}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can set these fields but use the value undefined and they won't appear in the HTML:

diff --git a/h/static/scripts/group-forms/components/forms/TextField.tsx b/h/static/scripts/group-forms/components/forms/TextField.tsx
index 0925b7177..bea45fcfe 100644
--- a/h/static/scripts/group-forms/components/forms/TextField.tsx
+++ b/h/static/scripts/group-forms/components/forms/TextField.tsx
@@ -78,7 +78,7 @@ export type TextFieldProps = {
  */
 export default function TextField({
   type = 'input',
-  inputType = 'text',
+  inputType,
   value,
   onChangeValue,
   minLength = 0,
@@ -87,7 +87,7 @@ export default function TextField({
   required = false,
   autofocus = false,
   classes = '',
-  name = '',
+  name,
   fieldError = '',
 }: TextFieldProps) {
   const id = useId();
@@ -131,8 +131,8 @@ export default function TextField({
         autofocus={autofocus}
         autocomplete="off"
         required={required}
-        {...(name ? { name: name } : {})}
-        {...(inputType === 'password' ? { type: 'password' } : {})}
+        name={name}
+        type={inputType}
       />
       {typeof maxLength === 'number' && (
         <CharacterCounter

<Config.Provider value={config}>
<Router>
<Switch>
<Route path={routes.login}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think that will make sense.

<span>
{/* These are in a child span to avoid a gap between them. */}
<Star />
&nbsp;Required
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a login form I think it is sufficiently obvious that the username and password are required that we can omit this visual indicator. We do need to make sure that the required field is still set on the <input> though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, should've made it the same as the original form rather than taking that from group form. Thanks.

};

/** Return the frontend config from the page's <script class="js-config">. */
export function readConfig(): ConfigObject {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it will make sense to re-use the readConfig function from the group forms module, and make that generic, but this can be done as part of some refactoring after this lands.


function init() {
const shadowHost = document.querySelector('#login-form')!;
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect we can simplify things for ourselves by removing the use of shadow DOM in this form, but that's out of scope for this PR.

@@ -0,0 +1,6 @@
/* CSS entry point for the login app. */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file appears to be unused.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, removed

csrf_token = get_csrf_token(self.request)

return {
"styles": self.request.registry["assets_env"].urls("group_forms_css"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something to know about how Tailwind works is that it scans a specified set of files looking for Tailwind classes that are used and only generates those styles in the CSS. The set of files that are scanned includes all of our TSX files under h/static/scripts, so re-using the CSS here will work. It will probably make sense to rename this bundle to something more general.

@mtomilov mtomilov force-pushed the port-login-page-to-preact branch 3 times, most recently from 1544491 to 68ea2f0 Compare May 28, 2025 17:20
@mtomilov mtomilov force-pushed the port-login-page-to-preact branch from 68ea2f0 to 671f84b Compare May 28, 2025 17:28
@mtomilov
Copy link
Contributor Author

mtomilov commented May 28, 2025

This is looking good overall from an initial glance. I think we should remove the visible "required" markers from the fields in this form, though we will need to keep the required attribute on the form field. This will require a small change to the TextField API.

Added separate showRequired argument, defaulting to required which we set in LoginForm.
Probably we want opt-out behavior for the indication here.

@mtomilov mtomilov requested a review from robertknight May 28, 2025 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants