Skip to content

Commit ca19928

Browse files
committed
Rewrite account settings forms in Preact
1 parent 3c21260 commit ca19928

File tree

14 files changed

+1115
-397
lines changed

14 files changed

+1115
-397
lines changed

h/accounts/schemas.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,11 @@ class SocialLoginSignupSchema(colander.Schema):
203203
comms_opt_in = comms_opt_in_node()
204204

205205

206-
class EmailChangeSchema(CSRFSchema):
207-
email = email_node(title=_("Email address"))
208-
# No validators: all validation is done on the email field
209-
password = password_node(title=_("Confirm password"), hide_until_form_active=True)
206+
class EmailChangeSchema(colander.Schema):
207+
email = email_node()
208+
password = password_node()
210209

211210
def validator(self, node, value):
212-
super().validator(node, value)
213211
exc = colander.Invalid(node)
214212
request = node.bindings["request"]
215213
svc = request.find_service(name="user_password")
@@ -257,15 +255,12 @@ def validator(self, node, value):
257255
raise exc
258256

259257

260-
class PasswordChangeSchema(CSRFSchema):
261-
password = password_node(title=_("Current password"), inactive_label=_("Password"))
262-
new_password = new_password_node(
263-
title=_("New password"), hide_until_form_active=True
264-
)
258+
class PasswordChangeSchema(colander.Schema):
259+
password = password_node()
260+
new_password = new_password_node()
265261
new_password_confirm = new_password_confirm_node()
266262

267-
def validator(self, node, value): # pragma: no cover
268-
super().validator(node, value)
263+
def validator(self, node, value):
269264
exc = colander.Invalid(node)
270265
request = node.bindings["request"]
271266
svc = request.find_service(name="user_password")

h/static/scripts/forms-common/components/TextField.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export type TextFieldProps = {
4040
/** Current value of the input. */
4141
value: string;
4242

43+
/** Placeholder text for the input. */
44+
placeholder?: string;
45+
4346
/** Callback invoked when the field's value is changed. */
4447
onChangeValue: (newValue: string) => void;
4548

@@ -96,6 +99,7 @@ export default function TextField({
9699
type = 'input',
97100
inputType,
98101
value,
102+
placeholder,
99103
onChangeValue,
100104
onCommitValue,
101105
minLength = 0,
@@ -135,6 +139,7 @@ export default function TextField({
135139
}}
136140
error={error}
137141
value={value}
142+
placeholder={placeholder}
138143
classes={classes}
139144
autofocus={autofocus}
140145
autocomplete="off"
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { Button } from '@hypothesis/frontend-shared';
2+
import { ExternalIcon, CheckIcon } from '@hypothesis/frontend-shared';
3+
import { useContext } from 'preact/hooks';
4+
5+
import Form from '../../forms-common/components/Form';
6+
import FormContainer from '../../forms-common/components/FormContainer';
7+
import TextField from '../../forms-common/components/TextField';
8+
import { useFormValue } from '../../forms-common/form-value';
9+
import type { FormValue } from '../../forms-common/form-value';
10+
import { Config } from '../config';
11+
import type { AccountSettingsConfigObject } from '../config';
12+
import FacebookIcon from './FacebookIcon';
13+
import GoogleIcon from './GoogleIcon';
14+
import ORCIDIcon from './ORCIDIcon';
15+
16+
const textFieldProps = (field: FormValue<string>) => ({
17+
value: field.value,
18+
fieldError: field.error,
19+
onChangeValue: field.update,
20+
onCommitValue: field.commit,
21+
});
22+
23+
function Heading({ text }: { text: string }) {
24+
return <h1 class="text-lg mb-4">{text}</h1>;
25+
}
26+
27+
function FormSubmitButton() {
28+
return (
29+
<div className="mb-8 pt-2 flex items-center gap-x-4">
30+
<div className="grow" />
31+
<Button type="submit" variant="primary" data-testid="submit-button">
32+
Save
33+
</Button>
34+
</div>
35+
);
36+
}
37+
38+
function ChangeEmailForm({ config }: { config: AccountSettingsConfigObject }) {
39+
const email = useFormValue(config.forms.email, 'email', '');
40+
const password = useFormValue(config.forms.email, 'password', '');
41+
42+
return (
43+
<FormContainer>
44+
<Heading text="Change your email address" />
45+
46+
<Form csrfToken={config.csrfToken} data-testid="email-form">
47+
<input type="hidden" name="__formid__" value="email" />
48+
<TextField
49+
name="email"
50+
label="Email address"
51+
placeholder={config.context.user?.email}
52+
required={true}
53+
{...textFieldProps(email)}
54+
/>
55+
<TextField
56+
name="password"
57+
label="Confirm password"
58+
inputType="password"
59+
required={true}
60+
{...textFieldProps(password)}
61+
/>
62+
<FormSubmitButton />
63+
</Form>
64+
</FormContainer>
65+
);
66+
}
67+
68+
function ChangePasswordForm({
69+
config,
70+
}: {
71+
config: AccountSettingsConfigObject;
72+
}) {
73+
const password = useFormValue(config.forms.password, 'password', '');
74+
const newPassword = useFormValue(config.forms.password, 'new_password', '');
75+
const newPasswordConfirm = useFormValue(
76+
config.forms.password,
77+
'new_password_confirm',
78+
'',
79+
);
80+
81+
return (
82+
<FormContainer>
83+
<Heading text="Change your password" />
84+
85+
<Form csrfToken={config.csrfToken} data-testid="password-form">
86+
<input type="hidden" name="__formid__" value="password" />
87+
<TextField
88+
name="password"
89+
label="Current password"
90+
inputType="password"
91+
required={true}
92+
{...textFieldProps(password)}
93+
/>
94+
<TextField
95+
name="new_password"
96+
label="New password"
97+
inputType="password"
98+
required={true}
99+
{...textFieldProps(newPassword)}
100+
/>
101+
<TextField
102+
name="new_password_confirm"
103+
label="Confirm new password"
104+
inputType="password"
105+
required={true}
106+
{...textFieldProps(newPasswordConfirm)}
107+
/>
108+
<FormSubmitButton />
109+
</Form>
110+
</FormContainer>
111+
);
112+
}
113+
114+
function ConnectAccountButton({
115+
config,
116+
provider,
117+
}: {
118+
config: AccountSettingsConfigObject;
119+
provider: 'facebook' | 'google' | 'orcid';
120+
}) {
121+
const providerConfig = config.context.identities?.[provider];
122+
const connected = providerConfig?.connected;
123+
const providerUniqueID = providerConfig?.provider_unique_id;
124+
const providerURL = providerConfig?.url;
125+
const connectURL = config.routes?.[`oidc.connect.${provider}`];
126+
const Icon = {
127+
google: GoogleIcon,
128+
facebook: FacebookIcon,
129+
orcid: ORCIDIcon,
130+
}[provider];
131+
const label = {
132+
google: (
133+
<>
134+
Connect <b>Google</b>
135+
</>
136+
),
137+
facebook: (
138+
<>
139+
Connect <b>Facebook</b>
140+
</>
141+
),
142+
orcid: (
143+
<>
144+
Connect <b>ORCID</b>
145+
</>
146+
),
147+
}[provider];
148+
149+
return (
150+
<>
151+
{connected ? (
152+
<a
153+
href={providerURL}
154+
className="border rounded-md p-3 flex flex-row items-center gap-x-3"
155+
data-testid={`connect-account-link-${provider}`}
156+
>
157+
<Icon />
158+
<span className="grow">
159+
Connected: <b>{providerUniqueID}</b>
160+
</span>
161+
<CheckIcon className="w-[20px] h-[20px]" />
162+
</a>
163+
) : (
164+
<a
165+
href={connectURL}
166+
className="border rounded-md p-3 flex flex-row items-center gap-x-3"
167+
data-testid={`connect-account-link-${provider}`}
168+
>
169+
<Icon />
170+
<span className="grow">{label}</span>
171+
<ExternalIcon className="w-[20px] h-[20px]" />
172+
</a>
173+
)}
174+
</>
175+
);
176+
}
177+
178+
function ConnectAccountButtons({
179+
config,
180+
}: {
181+
config: AccountSettingsConfigObject;
182+
}) {
183+
if (
184+
!(
185+
config.features.log_in_with_google ||
186+
config.features.log_in_with_facebook ||
187+
config.features.log_in_with_orcid
188+
)
189+
) {
190+
return null;
191+
}
192+
193+
return (
194+
<div class="text-grey-6 text-sm/relaxed" data-testid="connect-your-account">
195+
<Heading text="Connect your account" />
196+
197+
<p className="mb-9">
198+
Connecting an account enables you to log in to Hypothesis without your
199+
Hypothesis password.
200+
</p>
201+
202+
<div class="mx-auto max-w-[400px] flex flex-col gap-y-3">
203+
{config.features.log_in_with_google && (
204+
<ConnectAccountButton config={config} provider="google" />
205+
)}
206+
{config.features.log_in_with_facebook && (
207+
<ConnectAccountButton config={config} provider="facebook" />
208+
)}
209+
{config.features.log_in_with_orcid && (
210+
<ConnectAccountButton config={config} provider="orcid" />
211+
)}
212+
</div>
213+
</div>
214+
);
215+
}
216+
217+
export default function AccountSettingsForms() {
218+
const config = useContext(Config) as AccountSettingsConfigObject;
219+
220+
return (
221+
<>
222+
<ChangeEmailForm config={config} />
223+
<ChangePasswordForm config={config} />
224+
<ConnectAccountButtons config={config} />
225+
</>
226+
);
227+
}

h/static/scripts/login-forms/components/AppRoot.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Router from '../../forms-common/components/Router';
77
import type { ConfigObject } from '../config';
88
import { Config } from '../config';
99
import { routes } from '../routes';
10+
import AccountSettingsForms from './AccountSettingsForms';
1011
import LoginForm from './LoginForm';
1112
import SignupForm from './SignupForm';
1213
import SignupSelectForm from './SignupSelectForm';
@@ -62,6 +63,9 @@ export default function AppRoot({ config }: AppRootProps) {
6263
<Route path={routes.signupWithORCID}>
6364
<SignupForm idProvider="orcid" />
6465
</Route>
66+
<Route path={routes.accountSettings}>
67+
<AccountSettingsForms />
68+
</Route>
6569
<Route>
6670
<h1 data-testid="unknown-route">Page not found</h1>
6771
</Route>

0 commit comments

Comments
 (0)