Skip to content

Commit dfb86e1

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

File tree

14 files changed

+1242
-412
lines changed

14 files changed

+1242
-412
lines changed

h/accounts/schemas.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,15 @@ 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 EmailAddSchema(colander.Schema):
207+
email = email_node()
208+
209+
210+
class EmailChangeSchema(colander.Schema):
211+
email = email_node()
212+
password = password_node()
210213

211214
def validator(self, node, value):
212-
super().validator(node, value)
213215
exc = colander.Invalid(node)
214216
request = node.bindings["request"]
215217
svc = request.find_service(name="user_password")
@@ -241,13 +243,11 @@ def new_password_confirm_node():
241243
)
242244

243245

244-
class PasswordAddSchema(CSRFSchema):
246+
class PasswordAddSchema(colander.Schema):
245247
new_password = new_password_node(title=_("Add password"))
246248
new_password_confirm = new_password_confirm_node()
247249

248250
def validator(self, node, value):
249-
super().validator(node, value)
250-
251251
exc = colander.Invalid(node)
252252

253253
if value.get("new_password") != value.get("new_password_confirm"):
@@ -257,15 +257,12 @@ def validator(self, node, value):
257257
raise exc
258258

259259

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-
)
260+
class PasswordChangeSchema(colander.Schema):
261+
password = password_node()
262+
new_password = new_password_node()
265263
new_password_confirm = new_password_confirm_node()
266264

267-
def validator(self, node, value): # pragma: no cover
268-
super().validator(node, value)
265+
def validator(self, node, value):
269266
exc = colander.Invalid(node)
270267
request = node.bindings["request"]
271268
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: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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+
{config.context.user.email ? (
45+
<Heading text="Change your email address" />
46+
) : (
47+
<Heading text="Add an email address" />
48+
)}
49+
50+
<Form csrfToken={config.csrfToken} data-testid="email-form">
51+
<input type="hidden" name="__formid__" value="email" />
52+
<TextField
53+
name="email"
54+
label="Email address"
55+
placeholder={config.context.user?.email}
56+
required={true}
57+
{...textFieldProps(email)}
58+
/>
59+
{config.context.user.has_password && (
60+
<TextField
61+
name="password"
62+
label="Confirm password"
63+
inputType="password"
64+
required={true}
65+
{...textFieldProps(password)}
66+
/>
67+
)}
68+
<FormSubmitButton />
69+
</Form>
70+
</FormContainer>
71+
);
72+
}
73+
74+
function ChangePasswordForm({
75+
config,
76+
}: {
77+
config: AccountSettingsConfigObject;
78+
}) {
79+
const password = useFormValue(config.forms.password, 'password', '');
80+
const newPassword = useFormValue(config.forms.password, 'new_password', '');
81+
const newPasswordConfirm = useFormValue(
82+
config.forms.password,
83+
'new_password_confirm',
84+
'',
85+
);
86+
87+
return (
88+
<FormContainer>
89+
{config.context.user.has_password ? (
90+
<Heading text="Change your password" />
91+
) : (
92+
<Heading text="Add a password" />
93+
)}
94+
95+
<Form csrfToken={config.csrfToken} data-testid="password-form">
96+
<input type="hidden" name="__formid__" value="password" />
97+
{config.context.user.has_password && (
98+
<TextField
99+
name="password"
100+
label="Current password"
101+
inputType="password"
102+
required={true}
103+
{...textFieldProps(password)}
104+
/>
105+
)}
106+
<TextField
107+
name="new_password"
108+
label="New password"
109+
inputType="password"
110+
required={true}
111+
minLength={8}
112+
{...textFieldProps(newPassword)}
113+
/>
114+
<TextField
115+
name="new_password_confirm"
116+
label="Confirm new password"
117+
inputType="password"
118+
required={true}
119+
{...textFieldProps(newPasswordConfirm)}
120+
/>
121+
<FormSubmitButton />
122+
</Form>
123+
</FormContainer>
124+
);
125+
}
126+
127+
function ConnectAccountButton({
128+
config,
129+
provider,
130+
}: {
131+
config: AccountSettingsConfigObject;
132+
provider: 'facebook' | 'google' | 'orcid';
133+
}) {
134+
const providerConfig = config.context.identities?.[provider];
135+
const connected = providerConfig?.connected;
136+
const providerUniqueID = providerConfig?.provider_unique_id;
137+
const providerURL = providerConfig?.url;
138+
const connectURL = config.routes?.[`oidc.connect.${provider}`];
139+
const Icon = {
140+
google: GoogleIcon,
141+
facebook: FacebookIcon,
142+
orcid: ORCIDIcon,
143+
}[provider];
144+
const label = {
145+
google: (
146+
<>
147+
Connect <b>Google</b>
148+
</>
149+
),
150+
facebook: (
151+
<>
152+
Connect <b>Facebook</b>
153+
</>
154+
),
155+
orcid: (
156+
<>
157+
Connect <b>ORCID</b>
158+
</>
159+
),
160+
}[provider];
161+
162+
return (
163+
<>
164+
{connected ? (
165+
<a
166+
href={providerURL}
167+
className="border rounded-md p-3 flex flex-row items-center gap-x-3"
168+
data-testid={`connect-account-link-${provider}`}
169+
>
170+
<Icon />
171+
<span className="grow">
172+
Connected: <b>{providerUniqueID}</b>
173+
</span>
174+
<CheckIcon className="w-[20px] h-[20px]" />
175+
</a>
176+
) : (
177+
<a
178+
href={connectURL}
179+
className="border rounded-md p-3 flex flex-row items-center gap-x-3"
180+
data-testid={`connect-account-link-${provider}`}
181+
>
182+
<Icon />
183+
<span className="grow">{label}</span>
184+
<ExternalIcon className="w-[20px] h-[20px]" />
185+
</a>
186+
)}
187+
</>
188+
);
189+
}
190+
191+
function ConnectAccountButtons({
192+
config,
193+
}: {
194+
config: AccountSettingsConfigObject;
195+
}) {
196+
if (
197+
!(
198+
config.features.log_in_with_google ||
199+
config.features.log_in_with_facebook ||
200+
config.features.log_in_with_orcid
201+
)
202+
) {
203+
return null;
204+
}
205+
206+
return (
207+
<div class="text-grey-6 text-sm/relaxed" data-testid="connect-your-account">
208+
<Heading text="Connect your account" />
209+
210+
<p className="mb-9">
211+
Connecting an account enables you to log in to Hypothesis without your
212+
Hypothesis password.
213+
</p>
214+
215+
<div class="mx-auto max-w-[400px] flex flex-col gap-y-3">
216+
{config.features.log_in_with_google && (
217+
<ConnectAccountButton config={config} provider="google" />
218+
)}
219+
{config.features.log_in_with_facebook && (
220+
<ConnectAccountButton config={config} provider="facebook" />
221+
)}
222+
{config.features.log_in_with_orcid && (
223+
<ConnectAccountButton config={config} provider="orcid" />
224+
)}
225+
</div>
226+
</div>
227+
);
228+
}
229+
230+
export default function AccountSettingsForms() {
231+
const config = useContext(Config) as AccountSettingsConfigObject;
232+
233+
return (
234+
<>
235+
<ChangeEmailForm config={config} />
236+
<ChangePasswordForm config={config} />
237+
<ConnectAccountButtons config={config} />
238+
</>
239+
);
240+
}

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)