Skip to content

Commit d817d17

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

File tree

14 files changed

+1234
-412
lines changed

14 files changed

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

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)