docs: Make registration form self-explanatory.

This commit is contained in:
Ruben Verborgh 2021-07-29 16:49:52 +02:00 committed by Joachim Van Herwegen
parent e99f670252
commit 969bb0ee6c
10 changed files with 283 additions and 142 deletions

View File

@ -31,13 +31,16 @@ export function throwIdpInteractionError(error: unknown, prefilled: Record<strin
* @param confirmPassword - Confirmation of password to match.
*/
export function assertPassword(password: any, confirmPassword: any): asserts password is string {
assert(typeof password === 'string' && password.length > 0, 'Password required');
assert(
typeof password === 'string' && password.length > 0,
'Please enter a password.',
);
assert(
typeof confirmPassword === 'string' && confirmPassword.length > 0,
'Password confirmation required',
'Please confirm your password.',
);
assert(
password === confirmPassword,
'Password and confirmation do not match',
'Your password and confirmation did not match.',
);
}

View File

@ -193,14 +193,12 @@ export class RegistrationHandler extends HttpHandler {
*/
private async parseInput(request: HttpRequest): Promise<ParsedInput> {
const parsed = await getFormDataRequestBody(request);
let prefilled: Record<string, string> = {};
const prefilled: Record<string, string> = {};
try {
for (const key of Object.keys(parsed)) {
if (Array.isArray(parsed[key])) {
throw new Error(`Multiple values found for key ${key}`);
}
for (const [ key, value ] of Object.entries(parsed)) {
assert(!Array.isArray(value), `Unexpected multiple values for ${key}.`);
prefilled[key] = value ? value.trim() : '';
}
prefilled = parsed as Record<string, string>;
return this.validateInput(prefilled);
} catch (err: unknown) {
throwIdpInteractionError(err, prefilled);
@ -212,54 +210,38 @@ export class RegistrationHandler extends HttpHandler {
* Verifies that all the data combinations make sense.
*/
private validateInput(parsed: NodeJS.Dict<string>): ParsedInput {
const { email, password, confirmPassword, podName, webId, template, createWebId, register, createPod } = parsed;
const { email, password, confirmPassword, webId, podName, register, createPod, createWebId, template } = parsed;
assert(typeof email === 'string' && email.length > 0 && emailRegex.test(email),
'A valid e-mail address is required');
// Parse email
assert(typeof email === 'string' && emailRegex.test(email), 'Please enter a valid e-mail address.');
const result: ParsedInput = {
const validated: ParsedInput = {
email,
template,
register: Boolean(register) || Boolean(createWebId),
createPod: Boolean(createPod) || Boolean(createWebId),
createWebId: Boolean(createWebId),
register: Boolean(register),
createPod: Boolean(createPod),
};
assert(validated.register || validated.createPod, 'Please register for a WebID or create a Pod.');
const validWebId = typeof webId === 'string' && webId.length > 0;
if (result.createWebId) {
if (validWebId) {
throw new Error('A WebID should only be provided when no new one is being created');
}
} else {
if (!validWebId) {
throw new Error('A WebID is required if no new one is being created');
}
result.webId = webId;
// Parse WebID
if (!validated.createWebId) {
assert(typeof webId === 'string' && /^https?:\/\/[^/]+/u.test(webId), 'Please enter a valid WebID.');
validated.webId = webId;
}
if (result.register) {
// Parse Pod name
if (validated.createWebId || validated.createPod) {
assert(typeof podName === 'string' && podName.length > 0, 'Please specify a Pod name.');
validated.podName = podName;
}
// Parse account
if (validated.register) {
assertPassword(password, confirmPassword);
result.password = password;
} else if (typeof password === 'string' && password.length > 0) {
throw new Error('A password should only be provided when registering');
validated.password = password;
}
if (result.createWebId || result.createPod) {
assert(typeof podName === 'string' && podName.length > 0,
'A pod name is required when creating a pod and/or WebID');
result.podName = podName;
} else if (typeof podName === 'string' && podName.length > 0) {
throw new Error('A pod name should only be provided when creating a pod and/or WebID');
}
if (result.createWebId && !(result.register && result.createPod)) {
throw new Error('Creating a WebID is only possible when also registering and creating a pod');
}
if (!result.createWebId && !result.register && !result.createPod) {
throw new Error('At least one option needs to be chosen');
}
return result;
return validated;
}
}

View File

@ -19,6 +19,7 @@
<% } %>
<fieldset>
<legend>Your account</legend>
<ol>
<li>
<label for="email">Email</label>

View File

@ -19,9 +19,9 @@
</p>
<% if (createPod) { %>
<h2>Your new pod</h2>
<h2>Your new Pod</h2>
<p>
Your new pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>.
Your new Pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>.
<br>
You can store your documents and data there.
</p>
@ -37,18 +37,18 @@
<% } %>
<% if (register) { %>
<h2>Your new login</h2>
<h2>Your new account</h2>
<p>
You can <a href="./login">log in</a>
with your WebID <a href="<%= webId %>" class="link"><%= webId %></a>
on this server via your email address <em><%= email %></em>.
Via your email address <em><%= email %></em>,
this server lets you <a href="./login">log in</a> to Solid apps
with your WebID <a href="<%= webId %>" class="link"><%= webId %></a>
</p>
<% if (!createWebId) { %>
<p>
You will need to add the triple
<code><%= `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${oidcIssuer}>.`%></code>
to your existing WebID document <em><%= webId %></em>
to indicate that you trust this server as a login provider for your WebID.
to indicate that you trust this server as a login provider.
</p>
<% } %>
<% } %>

View File

@ -12,58 +12,195 @@
<h1>Community Solid Server</h1>
</header>
<main>
<h1>Sign up for an account</h1>
<form action="/idp/register" method="post">
<h1>Sign up</h1>
<form action="/idp/register" method="post" id="mainForm">
<% const isBlankForm = !('email' in prefilled); %>
<%if (errorMessage) { %>
<% if (errorMessage) { %>
<p class="error">Error: <%= errorMessage %></p>
<% } %>
<fieldset>
<legend>Your WebID</legend>
<p>
A <em>WebID</em> is a unique identifier for you
in the form of a URL.
<br>
You WebID lets you log in to Solid apps
and access non-public data in Pods.
</p>
<ol>
<li>
<label for="email">Email</label>
<input id="email" type="text" name="email" autofocus <% if (prefilled.email) { %> value="<%= prefilled.email %>" <% } %> />
<li class="radio">
<label>
<input type="radio" id="createWebIdOn" name="createWebId" value="on"<%
if (isBlankForm || prefilled.createWebId) { %> checked<% } %>>
Create a new WebID for my Pod.
</label>
<p id="createWebIdForm">
Please also create a Pod below, since your WebID will be stored there.
</p>
</li>
<li>
<label for="password">Password</label>
<input id="password" type="password" name="password" />
</li>
<li>
<label for="confirmPassword">Confirm password</label>
<input id="confirmPassword" type="password" name="confirmPassword" />
<li class="radio">
<label>
<input type="radio" id="createWebIdOff" name="createWebId" value=""<%
if (!isBlankForm && !prefilled.createWebId) { %> checked<% } %>>
Use my existing WebID to access my Pod.
</label>
<ol id="existingWebIdForm">
<li>
<label for="webId">Existing WebID:</label>
<input id="webId" type="text" name="webId" value="<%= prefilled.webId || '' %>">
</li>
<li class="checkbox">
<label>
<input type="checkbox" id="register" name="register"<%
if (isBlankForm || prefilled.register) { %> checked<% } %>>
Use my new account to log in with this WebID.
</label>
</li>
</ol>
</li>
</ol>
</fieldset>
<fieldset>
<legend>Your Pod</legend>
<p>
A Pod is a place to store your data.
<br>
If you create a new WebID, you must also create a Pod to store that WebID.
</p>
<ol>
<li class="checkbox">
<label><input type="checkbox" name="createWebId" checked>Create a new WebID <em>(requires the other two options)</em></label>
</li>
<li class="checkbox">
<label><input type="checkbox" name="register" checked>Register your WebID with the IDP</label>
<ol>
<label>
<input type="checkbox" id="createPod" name="createPod"<%
if (isBlankForm || prefilled.createPod) { %> checked<% } %>>
Create a new Pod with my WebID as owner.
</label>
<ol id="createPodForm">
<li>
<label for="webId">Alternatively, existing WebID</label>
<input id="webId" type="text" name="webId" <% if (prefilled.webId) { %> value="<%= prefilled.webId %>" <% } %> />
</li>
</ol>
</li>
<li class="checkbox">
<label><input type="checkbox" name="createPod" checked>Create a new pod</label>
<ol>
<li>
<label for="podName">Pod name</label>
<input id="podName" type="text" name="podName" <% if (prefilled.podName) { %> value="<%= prefilled.podName %>" <% } %> />
<label for="podName">Pod name:</label>
<input id="podName" type="text" name="podName" <%
if (prefilled.podName) { %> value="<%= prefilled.podName %>" <% } %>>
</li>
</ol>
</li>
</ol>
</fieldset>
<fieldset>
<legend>Your account</legend>
<div>
<p>
Choose the credentials you want to use to log in to this server in the future.
</p>
<ol>
<li>
<label for="email">Email:</label>
<input id="email" type="text" name="email" <%
if (prefilled.email) { %> value="<%= prefilled.email %>" <% } %>>
</li>
</ol>
<ol id="passwordForm">
<li>
<label for="password">Password:</label>
<input id="password" type="password" name="password">
</li>
<li>
<label for="confirmPassword">Confirm password:</label>
<input id="confirmPassword" type="password" name="confirmPassword">
</li>
</ol>
</div>
<div id="noPasswordForm" class="hidden">
<p>
Since you will be using your existing WebID setup to access your pod,
<br>
you do <em>not</em> need to set a password.
</p>
</div>
</fieldset>
<p class="actions"><button type="submit" name="submit">Sign up</button></p>
</form>
<script>
// Assist the user with filling out the form by hiding irrelevant fields
(() => {
// Wire up the UI elements
const elements = {};
[
'createWebIdOn', 'createWebIdOff', 'createWebIdForm', 'existingWebIdForm', 'webId',
'createPod', 'createPodForm', 'podName',
'register', 'passwordForm', 'noPasswordForm', 'mainForm',
].forEach(id => {
elements[id] = document.getElementById(id);
elements[id].addEventListener('change', updateUI);
});
updateUI();
mainForm.classList.add('loaded');
// Updates the UI when something has changed
function updateUI({ srcElement } = {}) {
// When Pod creation is required, automatically tick the corresponding checkbox
if (elements.createWebIdOn.checked)
elements.createPod.checked = true;
elements.createPod.disabled = elements.createWebIdOn.checked;
// Hide irrelevant fields
setVisibility('createWebIdForm', elements.createWebIdOn.checked);
setVisibility('existingWebIdForm', elements.createWebIdOff.checked);
setVisibility('createPodForm', elements.createPod.checked);
setVisibility('passwordForm', elements.createWebIdOn.checked || elements.register.checked);
setVisibility('noPasswordForm', !isVisible('passwordForm'));
// If child elements have just been activated, focus on them
if (srcElement?.checked) {
switch(document.activeElement) {
case elements.createWebIdOff:
const { webId } = elements;
webId.value = webId.value.startsWith('http') ? webId.value : 'https://';
webId.focus();
break;
case elements.createPod:
elements.podName.focus();
break;
}
}
}
// Checks whether the given element is visible
function isVisible(element) {
return !(elements[element] ?? element).classList.contains('hidden');
}
// Sets the visibility of the given element
function setVisibility(element, visible) {
// Show or hide the element
element = elements[element] ?? element;
element.classList[visible ? 'remove' : 'add']('hidden');
// Disable children of hidden elements,
// such that the browser does not expect input for them
for (const child of getDescendants(element)) {
if ('disabled' in child)
child.disabled = !visible;
}
}
// Obtains all children, grandchildren, etc. of the given element
function getDescendants(element) {
return [...element.querySelectorAll("*")];
}
// Enable all elements on form submission (otherwise their value is not submitted)
elements.mainForm.addEventListener('submit', () => {
for (const child of getDescendants(elements.mainForm))
child.disabled = false;
});
elements.mainForm.addEventListener('formdata', updateUI);
})();
</script>
</main>
<footer>
<p>

View File

@ -72,10 +72,14 @@ header img {
main h1 {
margin: 1em 0 0;
font-size: 2em;
font-weight: 700;
}
main h2 {
margin: 1em 0 0;
font-size: 1.5em;
font-weight: 600;
color: var(--solid-gray);
}
@ -134,35 +138,49 @@ pre {
fieldset {
border: none;
margin: 0;
padding: 0 0 0 1em;
padding: 0;
max-width: 600px;
}
fieldset > legend {
margin: .5em 0 0;
font-size: 1.5em;
font-weight: 600;
color: var(--solid-gray);
}
fieldset > legend + p {
margin: 0 0 .5em .25em;
}
fieldset ol {
list-style: none;
margin; 0;
padding: 0;
margin: 0;
padding: 0 0 0 2em;
}
fieldset ol ol {
margin: 0 0 1em 2em;
margin: 0 0 1em .5em;
}
fieldset ol li:not(.checkbox) {
fieldset ol li:not(.checkbox, .radio) {
display: grid;
grid-template-columns: 10em auto;
grid-gap: 1em;
align-items: center;
}
fieldset ol ol li:not(.checkbox) {
grid-template-columns: 8em auto;
fieldset ol ol li:not(.checkbox, .radio) {
grid-template-columns: 7.5em auto;
}
fieldset li label {
font-weight: 600;
}
fieldset li:not(.checkbox) > label:after {
content: ": ";
fieldset li li label {
font-weight: 500;
}
fieldset li.checkbox > label > input {
fieldset li.checkbox > label > input,
fieldset li.radio > label > input {
margin: 0 .5em 0 0;
}
fieldset li p {
margin-top: .25em;
}
input {
border: 1px solid var(--solid-purple);
@ -183,7 +201,7 @@ button:hover {
}
form p.actions {
margin: 0 0 1em 12em;
margin: .5em 0 1em 11em;
}
form p.error {
@ -193,7 +211,7 @@ form p.error {
form ul.actions {
padding: 0;
margin: 0 0 1em 12em;
margin: 0 0 1em 11em;
}
form ul.actions > li {
list-style-type: none;
@ -201,6 +219,15 @@ form ul.actions > li {
margin-right: 1em;
}
form.loaded * {
max-height: 500px;
transition: max-height .2s;
}
form .hidden {
max-height: 0;
overflow: hidden;
}
ul.container > li {
margin: 0.25em 0;
list-style-type: none;

View File

@ -117,8 +117,8 @@ describe('A Solid server with IDP', (): void => {
const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toMatch(new RegExp(`your WebID.*${webId}`, 'u'));
expect(text).toMatch(new RegExp(`your email address.*${email}`, 'u'));
expect(text).toMatch(new RegExp(`your.WebID.*${webId}`, 'u'));
expect(text).toMatch(new RegExp(`your.email.address.*${email}`, 'u'));
expect(text).toMatch(new RegExp(`<code>&lt;${webId}&gt; &lt;http://www.w3.org/ns/solid/terms#oidcIssuer&gt; &lt;${baseUrl}&gt;\\.</code>`, 'mu'));
});
});
@ -280,7 +280,7 @@ describe('A Solid server with IDP', (): void => {
const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toMatch(new RegExp(`Your new pod.*${baseUrl}${podName}/`, 'u'));
expect(text).toMatch(new RegExp(`Your new Pod.*${baseUrl}${podName}/`, 'u'));
});
});
@ -305,7 +305,7 @@ describe('A Solid server with IDP', (): void => {
newWebId = matchWebId![1];
expect(text).toMatch(new RegExp(`new WebID is.*${newWebId}`, 'u'));
expect(text).toMatch(new RegExp(`your email address.*${newMail}`, 'u'));
expect(text).toMatch(new RegExp(`Your new pod.*${baseUrl}${podName}/`, 'u'));
expect(text).toMatch(new RegExp(`Your new Pod.*${baseUrl}${podName}/`, 'u'));
});
it('initializes the session and logs in.', async(): Promise<void> => {

View File

@ -53,12 +53,11 @@ describe('EmailPasswordUtil', (): void => {
describe('#assertPassword', (): void => {
it('validates the password against the confirmPassword.', async(): Promise<void> => {
expect((): void => assertPassword(undefined, undefined)).toThrow('Password required');
expect((): void => assertPassword([], undefined)).toThrow('Password required');
expect((): void => assertPassword('password', undefined)).toThrow('Password confirmation required');
expect((): void => assertPassword('password', [])).toThrow('Password confirmation required');
expect((): void => assertPassword('password', 'confirmPassword'))
.toThrow('Password and confirmation do not match');
expect((): void => assertPassword(undefined, undefined)).toThrow('Please enter a password.');
expect((): void => assertPassword([], undefined)).toThrow('Please enter a password.');
expect((): void => assertPassword('password', undefined)).toThrow('Please confirm your password.');
expect((): void => assertPassword('password', [])).toThrow('Please confirm your password.');
expect((): void => assertPassword('password', 'other')).toThrow('Your password and confirmation did not match');
expect(assertPassword('password', 'password')).toBeUndefined();
});
});

View File

@ -78,81 +78,73 @@ describe('A RegistrationHandler', (): void => {
describe('validating data', (): void => {
it('rejects array inputs.', async(): Promise<void> => {
request = createPostFormRequest({ data: [ 'a', 'b' ]});
await expect(handler.handle({ request, response })).rejects.toThrow('Multiple values found for key data');
request = createPostFormRequest({ mydata: [ 'a', 'b' ]});
await expect(handler.handle({ request, response }))
.rejects.toThrow('Unexpected multiple values for mydata.');
});
it('errors on invalid emails.', async(): Promise<void> => {
request = createPostFormRequest({ email: undefined });
await expect(handler.handle({ request, response })).rejects.toThrow('A valid e-mail address is required');
await expect(handler.handle({ request, response }))
.rejects.toThrow('Please enter a valid e-mail address.');
request = createPostFormRequest({ email: '' });
await expect(handler.handle({ request, response })).rejects.toThrow('A valid e-mail address is required');
await expect(handler.handle({ request, response }))
.rejects.toThrow('Please enter a valid e-mail address.');
request = createPostFormRequest({ email: 'invalidEmail' });
await expect(handler.handle({ request, response })).rejects.toThrow('A valid e-mail address is required');
});
it('errors when an unnecessary WebID is provided.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, createWebId });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A WebID should only be provided when no new one is being created');
.rejects.toThrow('Please enter a valid e-mail address.');
});
it('errors when a required WebID is not valid.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId: undefined });
request = createPostFormRequest({ email, register, webId: undefined });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A WebID is required if no new one is being created');
.rejects.toThrow('Please enter a valid WebID.');
request = createPostFormRequest({ email, webId: '' });
request = createPostFormRequest({ email, register, webId: '' });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A WebID is required if no new one is being created');
});
it('errors when an unnecessary password is provided.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A password should only be provided when registering');
.rejects.toThrow('Please enter a valid WebID.');
});
it('errors on invalid passwords when registering.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password, confirmPassword: 'bad', register });
await expect(handler.handle({ request, response })).rejects.toThrow('Password and confirmation do not match');
});
it('errors when an unnecessary pod name is provided.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, podName });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A pod name should only be provided when creating a pod and/or WebID');
.rejects.toThrow('Your password and confirmation did not match.');
});
it('errors on invalid pod names when required.', async(): Promise<void> => {
request = createPostFormRequest({ email, podName: undefined, createWebId });
request = createPostFormRequest({ email, webId, createPod, podName: undefined });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A pod name is required when creating a pod and/or WebID');
.rejects.toThrow('Please specify a Pod name.');
request = createPostFormRequest({ email, webId, podName: '', createPod });
request = createPostFormRequest({ email, webId, createPod, podName: ' ' });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A pod name is required when creating a pod and/or WebID');
.rejects.toThrow('Please specify a Pod name.');
request = createPostFormRequest({ email, webId, createWebId });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Please specify a Pod name.');
});
it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise<void> => {
request = createPostFormRequest({ email, podName, createWebId });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Creating a WebID is only possible when also registering and creating a pod');
request = createPostFormRequest({ email, podName, password, confirmPassword, createWebId, register });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Creating a WebID is only possible when also registering and creating a pod');
.rejects.toThrow('Please enter a password.');
request = createPostFormRequest({ email, podName, createWebId, createPod });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Creating a WebID is only possible when also registering and creating a pod');
.rejects.toThrow('Please enter a password.');
request = createPostFormRequest({ email, podName, createWebId, createPod, register });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Please enter a password.');
});
it('errors when no option is chosen.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId });
await expect(handler.handle({ request, response })).rejects.toThrow('At least one option needs to be chosen');
await expect(handler.handle({ request, response }))
.rejects.toThrow('Please register for a WebID or create a Pod.');
});
});

View File

@ -43,7 +43,7 @@ describe('A ResetPasswordHandler', (): void => {
});
it('errors for invalid passwords.', async(): Promise<void> => {
const errorMessage = 'Password and confirmation do not match';
const errorMessage = 'Your password and confirmation did not match.';
request = createPostFormRequest({ password: 'password!', confirmPassword: 'otherPassword!' }, url);
await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage);
});