Skip to content

Commit a684b2e

Browse files
committedFeb 11, 2022
feat: Update IDP templates to work with new API format
1 parent bc0eeb1 commit a684b2e

36 files changed

+645
-189
lines changed
 

‎LICENSE.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright © 2019–2021 Inrupt Inc. and imec
3+
Copyright © 2019–2022 Inrupt Inc. and imec
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

‎config/http/static/default.json

+8-3
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,22 @@
1212
"StaticAssetHandler:_assets_value": "@css:templates/images/favicon.ico"
1313
},
1414
{
15-
"StaticAssetHandler:_assets_key": "/.well_known/css/styles/",
15+
"StaticAssetHandler:_assets_key": "/.well-known/css/styles/",
1616
"StaticAssetHandler:_assets_value": "@css:templates/styles/"
1717
},
1818
{
19-
"StaticAssetHandler:_assets_key": "/.well_known/css/fonts/",
19+
"StaticAssetHandler:_assets_key": "/.well-known/css/fonts/",
2020
"StaticAssetHandler:_assets_value": "@css:templates/fonts/"
2121
},
2222
{
23-
"StaticAssetHandler:_assets_key": "/.well_known/css/images/",
23+
"StaticAssetHandler:_assets_key": "/.well-known/css/images/",
2424
"StaticAssetHandler:_assets_value": "@css:templates/images/"
25+
},
26+
{
27+
"StaticAssetHandler:_assets_key": "/.well-known/css/scripts/",
28+
"StaticAssetHandler:_assets_value": "@css:templates/scripts/"
2529
}
30+
2631
]
2732
}
2833
]

‎config/identity/handler/interaction/routes.json

+28-18
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"import": [
44
"files-scs:config/identity/handler/interaction/routes/existing-login.json",
55
"files-scs:config/identity/handler/interaction/routes/forgot-password.json",
6+
"files-scs:config/identity/handler/interaction/routes/index.json",
67
"files-scs:config/identity/handler/interaction/routes/login.json",
78
"files-scs:config/identity/handler/interaction/routes/prompt.json",
89
"files-scs:config/identity/handler/interaction/routes/reset-password.json",
@@ -21,26 +22,35 @@
2122
{
2223
"comment": "Adds controls and API version to JSON responses.",
2324
"@id": "urn:solid-server:auth:password:ControlHandler",
24-
"ControlHandler:_source" : {
25-
"@id": "urn:solid-server:auth:password:RouteInteractionHandler",
26-
"@type": "WaterfallHandler",
27-
"handlers": [
28-
{
29-
"comment": [
30-
"This handler is required to prevent Components.js issues with arrays.",
31-
"This might be fixed in the next Components.js release after which this can be removed."
32-
],
33-
"@type": "UnsupportedAsyncHandler"
34-
},
35-
{ "@id": "urn:solid-server:auth:password:PromptRoute" },
36-
{ "@id": "urn:solid-server:auth:password:LoginRoute" },
37-
{ "@id": "urn:solid-server:auth:password:ExistingLoginRoute" },
38-
{ "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" },
39-
{ "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }
40-
]
41-
}
25+
"ControlHandler:_source" : { "@id": "urn:solid-server:auth:password:LocationInteractionHandler" }
4226
}
4327
]
28+
},
29+
{
30+
"comment": "Converts redirect errors to location JSON responses.",
31+
"@id": "urn:solid-server:auth:password:LocationInteractionHandler",
32+
"@type": "LocationInteractionHandler",
33+
"LocationInteractionHandler:_source" : { "@id": "urn:solid-server:auth:password:RouteInteractionHandler" }
34+
},
35+
{
36+
"comment": "Handles every interaction based on their route.",
37+
"@id": "urn:solid-server:auth:password:RouteInteractionHandler",
38+
"@type": "WaterfallHandler",
39+
"handlers": [
40+
{
41+
"comment": [
42+
"This handler is required to prevent Components.js issues with arrays.",
43+
"This might be fixed in the next Components.js release after which this can be removed."
44+
],
45+
"@type": "UnsupportedAsyncHandler"
46+
},
47+
{ "@id": "urn:solid-server:auth:password:IndexRoute" },
48+
{ "@id": "urn:solid-server:auth:password:PromptRoute" },
49+
{ "@id": "urn:solid-server:auth:password:LoginRoute" },
50+
{ "@id": "urn:solid-server:auth:password:ExistingLoginRoute" },
51+
{ "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" },
52+
{ "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }
53+
]
4454
}
4555
]
4656
}

‎config/identity/handler/interaction/routes/existing-login.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.",
66
"@id": "urn:solid-server:auth:password:ExistingLoginRoute",
77
"@type": "RelativeInteractionRoute",
8-
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
9-
"relativePath": "/idp/consent/",
8+
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
9+
"relativePath": "/consent/",
1010
"source": {
1111
"@type": "ExistingLoginHandler",
1212
"interactionCompleter": { "@type": "BaseInteractionCompleter" }

‎config/identity/handler/interaction/routes/forgot-password.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"comment": "Handles the forgot password interaction",
66
"@id": "urn:solid-server:auth:password:ForgotPasswordRoute",
77
"@type": "RelativeInteractionRoute",
8-
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
9-
"relativePath": "/idp/forgotpassword/",
8+
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
9+
"relativePath": "/forgotpassword/",
1010
"source": {
1111
"@type": "ForgotPasswordHandler",
1212
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
3+
"@graph": [
4+
{
5+
"comment": "Root API entry. Returns an empty body so we can add controls pointing to other interaction routes.",
6+
"@id": "urn:solid-server:auth:password:IndexRoute",
7+
"@type": "RelativeInteractionRoute",
8+
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
9+
"relativePath": "/idp/",
10+
"source": {
11+
"@type": "FixedInteractionHandler",
12+
"response": {}
13+
}
14+
}
15+
]
16+
}

‎config/identity/handler/interaction/routes/login.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"comment": "Handles the login interaction",
66
"@id": "urn:solid-server:auth:password:LoginRoute",
77
"@type": "RelativeInteractionRoute",
8-
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
9-
"relativePath": "/idp/login/",
8+
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
9+
"relativePath": "/login/",
1010
"source": {
1111
"@type": "LoginHandler",
1212
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },

‎config/identity/handler/interaction/routes/prompt.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
"comment": "Handles OIDC redirects containing a prompt, such as login or consent.",
66
"@id": "urn:solid-server:auth:password:PromptRoute",
77
"@type": "RelativeInteractionRoute",
8-
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
9-
"relativePath": "/idp/",
8+
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
9+
"relativePath": "/prompt/",
1010
"source": {
1111
"@type": "PromptHandler",
12+
"@id": "urn:solid-server:auth:password:PromptHandler",
1213
"promptRoutes": [
1314
{
1415
"PromptHandler:_promptRoutes_key": "login",

‎config/identity/handler/interaction/routes/reset-password.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"comment": "Handles the reset password interaction",
66
"@id": "urn:solid-server:auth:password:ResetPasswordRoute",
77
"@type": "RelativeInteractionRoute",
8-
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
9-
"relativePath": "/idp/resetpassword/",
8+
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
9+
"relativePath": "/resetpassword/",
1010
"source": {
1111
"@type": "ResetPasswordHandler",
1212
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }

‎config/identity/handler/interaction/views/controls.json

+8
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
"@id": "urn:solid-server:auth:password:ControlHandler",
66
"@type": "ControlHandler",
77
"controls": [
8+
{
9+
"ControlHandler:_controls_key": "index",
10+
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:IndexRoute" }
11+
},
12+
{
13+
"ControlHandler:_controls_key": "prompt",
14+
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:PromptRoute" }
15+
},
816
{
917
"ControlHandler:_controls_key": "login",
1018
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:LoginRoute" }

‎config/identity/handler/interaction/views/html.json

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
{
55
"@id": "urn:solid-server:auth:password:HtmlViewHandler",
66
"@type": "HtmlViewHandler",
7+
"index": { "@id": "urn:solid-server:auth:password:IndexRoute" },
78
"templateEngine": {
89
"comment": "Renders the specific page and embeds it into the main HTML body.",
910
"@type": "ChainedTemplateEngine",

‎config/identity/handler/provider-factory/identity.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"args_adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" },
1212
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
1313
"args_oidcPath": "/.oidc",
14-
"args_idpPath": "/idp",
14+
"args_interactionHandler": { "@id": "urn:solid-server:auth:password:PromptHandler" },
1515
"args_storage": { "@id": "urn:solid-server:default:IdpKeyStorage" },
1616
"args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
1717
"args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },

‎config/identity/registration/enabled.json

+8
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@
3232
{
3333
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs",
3434
"HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" }
35+
},
36+
{
37+
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password-response.html.ejs",
38+
"HtmlViewHandler:_templates_value": {
39+
"@type": "RelativeInteractionRoute",
40+
"base": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" },
41+
"relativePath": "/response/"
42+
}
3543
}
3644
]
3745
}

‎config/identity/registration/route/registration.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"comment": "Handles the register interaction",
66
"@id": "urn:solid-server:auth:password:RegistrationRoute",
77
"@type": "RelativeInteractionRoute",
8-
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
9-
"relativePath": "/idp/register/",
8+
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
9+
"relativePath": "/register/",
1010
"source": {
1111
"@type": "RegistrationHandler",
1212
"registrationManager": {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/* eslint-disable tsdoc/syntax */
2+
// tsdoc/syntax cannot handle `@range`
3+
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
4+
import type { Representation } from '../../http/representation/Representation';
5+
import { APPLICATION_JSON } from '../../util/ContentTypes';
6+
import type { InteractionHandlerInput } from './InteractionHandler';
7+
import { InteractionHandler } from './InteractionHandler';
8+
9+
/**
10+
* An {@link InteractionHandler} that always returns the same JSON response on all requests.
11+
*/
12+
export class FixedInteractionHandler extends InteractionHandler {
13+
private readonly response: string;
14+
15+
/**
16+
* @param response - @range {json}
17+
*/
18+
public constructor(response: unknown) {
19+
super();
20+
this.response = JSON.stringify(response);
21+
}
22+
23+
public async handle({ operation }: InteractionHandlerInput): Promise<Representation> {
24+
return new BasicRepresentation(this.response, operation.target, APPLICATION_JSON);
25+
}
26+
}

‎src/identity/interaction/HtmlViewHandler.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,19 @@ import type { InteractionRoute } from './routing/InteractionRoute';
1818
* Will only handle GET operations for which there is a matching template if HTML is more preferred than JSON.
1919
* Reason for doing it like this instead of a standard content negotiation flow
2020
* is because we only want to return the HTML pages on GET requests. *
21+
*
22+
* Templates will receive the parameter `idpIndex` in their context pointing to the root index URL of the IDP API
23+
* and an `authenticating` parameter indicating if this is an active OIDC interaction.
2124
*/
2225
export class HtmlViewHandler extends InteractionHandler {
26+
private readonly idpIndex: string;
2327
private readonly templateEngine: TemplateEngine;
2428
private readonly templates: Record<string, string>;
2529

26-
public constructor(templateEngine: TemplateEngine, templates: Record<string, InteractionRoute>) {
30+
public constructor(index: InteractionRoute, templateEngine: TemplateEngine,
31+
templates: Record<string, InteractionRoute>) {
2732
super();
33+
this.idpIndex = index.getPath();
2834
this.templateEngine = templateEngine;
2935
this.templates = Object.fromEntries(
3036
Object.entries(templates).map(([ template, route ]): [ string, string ] => [ route.getPath(), template ]),
@@ -46,9 +52,10 @@ export class HtmlViewHandler extends InteractionHandler {
4652
}
4753
}
4854

49-
public async handle({ operation }: InteractionHandlerInput): Promise<Representation> {
55+
public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise<Representation> {
5056
const template = this.templates[operation.target.path];
51-
const result = await this.templateEngine.render({}, { templateFile: template });
57+
const contents = { idpIndex: this.idpIndex, authenticating: Boolean(oidcInteraction) };
58+
const result = await this.templateEngine.render(contents, { templateFile: template });
5259
return new BasicRepresentation(result, operation.target, TEXT_HTML);
5360
}
5461
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
2+
import type { Representation } from '../../http/representation/Representation';
3+
import { APPLICATION_JSON } from '../../util/ContentTypes';
4+
import { RedirectHttpError } from '../../util/errors/RedirectHttpError';
5+
import type { InteractionHandlerInput } from './InteractionHandler';
6+
import { InteractionHandler } from './InteractionHandler';
7+
8+
/**
9+
* Catches redirect errors from the source and returns a JSON body containing a `location` field instead.
10+
* This allows the API to be used more easily from the browser.
11+
*
12+
* The issue is that if the API actually did a redirect,
13+
* this would make it unusable when using it on HTML pages that need to render errors in case the fetch fails,
14+
* but want to redirect the page in case it succeeds.
15+
* See full overview at https://github.com/solid/community-server/pull/1088.
16+
*/
17+
export class LocationInteractionHandler extends InteractionHandler {
18+
private readonly source: InteractionHandler;
19+
20+
public constructor(source: InteractionHandler) {
21+
super();
22+
this.source = source;
23+
}
24+
25+
public async canHandle(input: InteractionHandlerInput): Promise<void> {
26+
await this.source.canHandle(input);
27+
}
28+
29+
public async handle(input: InteractionHandlerInput): Promise<Representation> {
30+
try {
31+
return await this.source.handle(input);
32+
} catch (error: unknown) {
33+
if (RedirectHttpError.isInstance(error)) {
34+
const body = JSON.stringify({ location: error.location });
35+
return new BasicRepresentation(body, input.operation.target, APPLICATION_JSON);
36+
}
37+
throw error;
38+
}
39+
}
40+
}

‎src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,10 @@ export * from './identity/interaction/BaseInteractionHandler';
160160
export * from './identity/interaction/CompletingInteractionHandler';
161161
export * from './identity/interaction/ExistingLoginHandler';
162162
export * from './identity/interaction/ControlHandler';
163+
export * from './identity/interaction/FixedInteractionHandler';
163164
export * from './identity/interaction/HtmlViewHandler';
164165
export * from './identity/interaction/InteractionHandler';
166+
export * from './identity/interaction/LocationInteractionHandler';
165167
export * from './identity/interaction/PromptHandler';
166168

167169
// Identity/Ownership
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
<h1>Authorize</h1>
22
<p>You are authorizing an application to access your Pod.</p>
3-
<form method="post">
4-
<% if (locals.message) { %>
5-
<p class="error"><%= message %></p>
6-
<% } %>
3+
<form method="post" id="mainForm">
4+
<p class="error" id="error"></p>
75

86
<fieldset>
97
<ol>
@@ -15,3 +13,7 @@
1513

1614
<p class="actions"><button autofocus type="submit" name="submit">Continue</button></p>
1715
</form>
16+
17+
<script>
18+
addPostListener('mainForm', 'error', '', () => { throw new Error('Expected a location field in the response.') });
19+
</script>

‎templates/identity/email-password/forgot-password-response.html.ejs

-13
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,45 @@
1-
<h1>Forgot password</h1>
2-
<form method="post">
3-
<% if (locals.message) { %>
4-
<p class="error"><%= message %></p>
5-
<% } %>
1+
<div id="input-partial">
2+
<h1>Forgot password</h1>
3+
<form method="post" id="mainForm">
4+
<p class="error" id="error"></p>
65

7-
<fieldset>
8-
<ol>
9-
<li>
10-
<label for="email">Email</label>
11-
<input id="email" type="email" name="email" autofocus>
12-
</li>
13-
</ol>
14-
</fieldset>
6+
<fieldset>
7+
<ol>
8+
<li>
9+
<label for="input-email">Email</label>
10+
<input id="input-email" type="email" name="email" autofocus>
11+
</li>
12+
</ol>
13+
</fieldset>
1514

16-
<p class="actions"><button type="submit" name="submit">Send recovery email</button></p>
15+
<p class="actions"><button type="submit" name="submit">Send recovery email</button></p>
1716

18-
<p class="actions"><a href="<%= controls.login %>" class="link">Log in</a></p>
19-
</form>
17+
<p class="actions"><a id="input-login-link" href="" class="link">Log in</a></p>
18+
</form>
19+
</div>
20+
<div id="response-partial">
21+
<h1>Email sent</h1>
22+
<p>If your account exists, an email has been sent with a link to reset your password.</p>
23+
<p>If you do not receive your email in a couple of minutes, check your spam folder or try sending another email.</p>
24+
25+
<ul class="actions">
26+
<li><a id="response-login-link" href="" class="link">Back to Log In</a></li>
27+
<li><a id="response-forgot-link" href="" class="link">Back to Forgot Password</a></li>
28+
</ul>
29+
</div>
30+
31+
<script>
32+
addControlLinks('<%= idpIndex %>', {
33+
'input-login-link': 'login',
34+
'response-login-link': 'login',
35+
'response-forgot-link': 'forgotPassword'
36+
});
37+
38+
setVisibility('response-partial', false);
39+
function updateResponse() {
40+
// Swap visibility
41+
setVisibility('input-partial', false);
42+
setVisibility('response-partial', true);
43+
}
44+
addPostListener('mainForm', 'error', '', updateResponse);
45+
</script>
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,56 @@
1-
<h1>Log in</h1>
2-
<form method="post">
3-
<% prefilled = locals.prefilled || {}; %>
1+
<div id="authenticating">
2+
<h1>Log in</h1>
3+
<form method="post" id="mainForm">
4+
<p class="error" id="error"></p>
45

5-
<% if (locals.message) { %>
6-
<p class="error"><%= message %></p>
7-
<% } %>
6+
<fieldset>
7+
<legend>Your account</legend>
8+
<ol>
9+
<li>
10+
<label for="email">Email</label>
11+
<input id="email" type="email" name="email" autofocus>
12+
</li>
13+
<li>
14+
<label for="password">Password</label>
15+
<input id="password" type="password" name="password">
16+
</li>
17+
<li class="checkbox">
18+
<label><input type="checkbox" name="remember" value="yes" checked>Stay logged in</label>
19+
</li>
20+
</ol>
21+
</fieldset>
822

9-
<fieldset>
10-
<legend>Your account</legend>
11-
<ol>
12-
<li>
13-
<label for="email">Email</label>
14-
<input id="email" type="email" name="email" autofocus value="<%= prefilled.email || '' %>">
15-
</li>
16-
<li>
17-
<label for="password">Password</label>
18-
<input id="password" type="password" name="password">
19-
</li>
20-
<li class="checkbox">
21-
<label><input type="checkbox" name="remember" value="yes" checked>Stay logged in</label>
22-
</li>
23-
</ol>
24-
</fieldset>
23+
<p class="actions"><button type="submit" name="submit">Log in</button></p>
2524

26-
<p class="actions"><button type="submit" name="submit">Log in</button></p>
25+
<ul class="actions">
26+
<li><a id="register-link" href="" class="link">Sign up</a></li>
27+
<li><a id="forgot-link" href="" class="link">Forgot password</a></li>
28+
</ul>
29+
</form>
30+
</div>
31+
<div id="not-authenticating">
32+
<h1>Please log in through an app</h1>
33+
<p><strong>To log in and access documents, you need to use a Solid app.</strong></p>
34+
<p>This server provides secure storage, but it is not a client app.</p>
35+
<p>
36+
Choose one of the
37+
<a href="https://solidproject.org/apps" class="link">Solid apps</a>
38+
to log in and browse Pods.
39+
</p>
40+
<p>
41+
If you're developing an app yourself,
42+
use a library such as
43+
<a href="https://github.com/inrupt/solid-client-authn-js" class="link"><code>solid-client-authn-js</code></a>
44+
to initiate an OIDC authentication flow.
45+
</p>
46+
</div>
2747

28-
<ul class="actions">
29-
<li><a href="<%= controls.register %>" class="link">Sign up</a></li>
30-
<li><a href="<%= controls.forgotPassword %>" class="link">Forgot password</a></li>
31-
</ul>
32-
</form>
48+
49+
<script>
50+
setVisibility('authenticating', <%= Boolean(authenticating) %>);
51+
setVisibility('not-authenticating', <%= !Boolean(authenticating) %>);
52+
53+
addPostListener('mainForm', 'error', '', () => { throw new Error('Expected a location field in the response.') });
54+
55+
addControlLinks('<%= idpIndex %>', { 'register-link': 'register', 'forgot-link': 'forgotPassword'});
56+
</script>

‎templates/identity/email-password/register-partial.html.ejs

+1-25
Original file line numberDiff line numberDiff line change
@@ -165,32 +165,8 @@
165165
}
166166
}
167167
168-
// Checks whether the given element is visible
169-
function isVisible(element) {
170-
return !(elements[element] ?? element).classList.contains('hidden');
171-
}
172-
173-
// Sets the visibility of the given element
174-
function setVisibility(element, visible) {
175-
// Show or hide the element
176-
element = elements[element] ?? element;
177-
element.classList[visible ? 'remove' : 'add']('hidden');
178-
179-
// Disable children of hidden elements,
180-
// such that the browser does not expect input for them
181-
for (const child of getDescendants(element)) {
182-
if ('disabled' in child)
183-
child.disabled = !visible;
184-
}
185-
}
186-
187-
// Obtains all children, grandchildren, etc. of the given element
188-
function getDescendants(element) {
189-
return [...element.querySelectorAll("*")];
190-
}
191-
192168
// Prepare the form when the DOM is ready
193-
window.addEventListener('DOMContentLoaded', (event) => {
169+
addEventListener('DOMContentLoaded', (event) => {
194170
synchronizeInputFields();
195171
elements.mainForm.classList.add('loaded');
196172
});
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,54 @@
1-
<% if (createPod) { %>
1+
<div id="response-createPod">
22
<h2>Your new Pod</h2>
33
<p>
4-
Your new Pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>.
4+
Your new Pod is located at <a id="response-podBaseUrl" href="" class="link"></a>.
55
<br>
66
You can store your documents and data there.
77
</p>
8-
<% } %>
8+
</div>
99

10-
<% if (createWebId) { %>
10+
<div id="response-createWebId">
1111
<h2>Your new WebID</h2>
1212
<p>
13-
Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>.
13+
Your new WebID is <a id="response-createdWebId" href="" class="link"></a>.
1414
<br>
1515
You can use this identifier to interact with Solid pods and apps.
1616
</p>
17-
<% } %>
17+
</div>
1818

19-
<% if (register) { %>
19+
<div id="response-register">
2020
<h2>Your new account</h2>
2121
<p>
22-
Via your email address <em><%= email %></em>,
23-
<% if (authenticating) { %>
24-
you can now <a href="<%= controls.login %>">log in</a>
25-
<% } else { %>
26-
this server lets you log in to Solid apps
27-
<% } %>
28-
with your WebID <a href="<%= webId %>" class="link"><%= webId %></a>
22+
Via your email address <em id="response-email"></em>,
23+
this server lets you log in to Solid apps
24+
with your WebID <a id="response-registeredWebId" href="" class="link"></a>
2925
</p>
30-
<% if (!createWebId) { %>
26+
<div id="response-registerWebId">
3127
<p>
3228
You will need to add the triple
33-
<code><%= `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${oidcIssuer}>.`%></code>
34-
to your existing WebID document <em><%= webId %></em>
29+
<code id="response-oidcIssuerTriple"></code>
30+
to your existing WebID document <em id="response-existingWebId"></em>
3531
to indicate that you trust this server as a login provider.
3632
</p>
37-
<% } %>
38-
<% } %>
33+
</div>
34+
<p>
35+
You can now <a id="response-login-link" href="">log in</a>.
36+
</p>
37+
</div>
38+
39+
<script>
40+
function updateResponseFields(json) {
41+
updateElement('response-podBaseUrl', json.podBaseUrl, { innerText: true, href: true });
42+
updateElement('response-createdWebId', json.webId, { innerText: true, href: true });
43+
updateElement('response-registeredWebId', json.webId, { innerText: true, href: true });
44+
const triple = `<${json.webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${json.oidcIssuer}>.`;
45+
updateElement('response-oidcIssuerTriple', triple, { innerText: true });
46+
updateElement('response-existingWebId', json.webId, { innerText: true });
47+
updateElement('response-email', json.email, { innerText: true });
48+
setVisibility('response-createPod', json.createPod);
49+
setVisibility('response-createWebId', json.createWebId);
50+
setVisibility('response-registerWebId', !json.createWebId);
51+
setVisibility('response-register', json.register);
52+
updateElement('response-login-link', json.controls.login, { href: true });
53+
}
54+
</script>

‎templates/identity/email-password/register-response.html.ejs

-7
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
1-
<h1>Sign up</h1>
2-
<form method="post" id="mainForm">
1+
<div id="input-partial">
2+
<h1>Sign up</h1>
3+
<form method="post" id="mainForm">
4+
<p class="error" id="error"></p>
35

4-
<% if (locals.message) { %>
5-
<p class="error">Error: <%= message %></p>
6-
<% } %>
6+
<%- include('./register-partial.html.ejs', { allowRoot: false }) %>
77

8-
<%- include('./register-partial.html.ejs', { allowRoot: false }) %>
8+
<p class="actions"><button type="submit" name="submit">Sign up</button></p>
9+
</form>
10+
</div>
11+
<div id="response-partial">
12+
<h1>You've been signed up</h1>
13+
<p>
14+
<strong>Welcome to Solid.</strong>
15+
We wish you an exciting experience!
16+
</p>
917

10-
<p class="actions"><button type="submit" name="submit">Sign up</button></p>
11-
</form>
18+
<%- include('./register-response-partial.html.ejs') %>
19+
</div>
20+
21+
<script>
22+
setVisibility('response-partial', false);
23+
function updateResponse(json) {
24+
// Swap visibility
25+
setVisibility('input-partial', false);
26+
setVisibility('response-partial', true);
27+
28+
updateResponseFields(json);
29+
}
30+
addPostListener('mainForm', 'error', '', updateResponse);
31+
</script>

‎templates/identity/email-password/reset-password-response.html.ejs

-2
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,40 @@
1-
<h1>Reset password</h1>
2-
<form method="post">
3-
<% if (locals.message) { %>
4-
<p class="error"><%= message %></p>
5-
<% } %>
1+
<div id="input-partial">
2+
<h1>Reset password</h1>
3+
<form method="post" id="mainForm">
4+
<p class="error" id="error"></p>
65

7-
<fieldset>
8-
<ol>
9-
<li>
10-
<label for="password">New password</label>
11-
<input id="password" type="password" name="password" placeholder="">
12-
</li>
13-
<li>
14-
<label for="confirmPassword">Confirm new password</label>
15-
<input id="confirmPassword" type="password" name="confirmPassword" placeholder="">
16-
</li>
17-
</ol>
18-
<input type="hidden" id="recordId" name="recordId" value="">
19-
</fieldset>
6+
<fieldset>
7+
<ol>
8+
<li>
9+
<label for="password">New password</label>
10+
<input id="password" type="password" name="password" placeholder="">
11+
</li>
12+
<li>
13+
<label for="confirmPassword">Confirm new password</label>
14+
<input id="confirmPassword" type="password" name="confirmPassword" placeholder="">
15+
</li>
16+
</ol>
17+
<input type="hidden" id="recordId" name="recordId" value="">
18+
</fieldset>
2019

21-
<p class="actions"><button type="submit" name="submit">Reset password</button></p>
22-
</form>
20+
<p class="actions"><button type="submit" name="submit">Reset password</button></p>
21+
</form>
22+
</div>
23+
<div id="response-partial">
24+
<h1>Password reset</h1>
25+
<p>Your password was successfully reset.</p>
26+
</div>
2327

2428
<script>
2529
const hidden = document.getElementById('recordId');
26-
const recordId = new URLSearchParams(window.location.search).get('rid');
30+
const recordId = new URLSearchParams(location.search).get('rid');
2731
hidden.value = recordId;
32+
33+
setVisibility('response-partial', false);
34+
function updateResponse() {
35+
// Swap visibility
36+
setVisibility('input-partial', false);
37+
setVisibility('response-partial', true);
38+
}
39+
addPostListener('mainForm', 'error', '', updateResponse);
2840
</script>

‎templates/main.html.ejs

+4-3
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,20 @@
44
<meta charset="utf-8"/>
55
<meta name="viewport" content="width=device-width, initial-scale=1"/>
66
<title><%= extractTitle(htmlBody) %></title>
7-
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
7+
<link rel="stylesheet" href="/.well-known/css/styles/main.css" type="text/css">
8+
<script type="text/javascript" src="/.well-known/css/scripts/util.js"></script>
89
</head>
910
<body>
1011
<header>
11-
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
12+
<a href="/"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
1213
<h1>Community Solid Server</h1>
1314
</header>
1415
<main>
1516
<%- htmlBody %>
1617
</main>
1718
<footer>
1819
<p>
19-
©2019–2021 <a href="https://inrupt.com/">Inrupt Inc.</a>
20+
©2019–2022 <a href="https://inrupt.com/">Inrupt Inc.</a>
2021
and <a href="https://www.imec-int.com/">imec</a>
2122
</p>
2223
</footer>

‎templates/root/prefilled/index.html

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
<meta charset="utf-8"/>
55
<meta name="viewport" content="width=device-width, initial-scale=1"/>
66
<title>Community Solid Server</title>
7-
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
7+
<link rel="stylesheet" href="/.well-known/css/styles/main.css" type="text/css">
88
</head>
99
<body>
1010
<header>
11-
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
11+
<a href="/"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
1212
<h1>Community Solid Server</h1>
1313
</header>
1414
<main>
@@ -58,7 +58,7 @@ <h2>Have a wonderful Solid experience</h2>
5858
</main>
5959
<footer>
6060
<p>
61-
©2019–2021 <a href="https://inrupt.com/">Inrupt Inc.</a>
61+
©2019–2022 <a href="https://inrupt.com/">Inrupt Inc.</a>
6262
and <a href="https://www.imec-int.com/">imec</a>
6363
</p>
6464
</footer>

‎templates/scripts/util.js

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Acquires all data from the given form and POSTs it as JSON to the target URL.
3+
* In case of failure this function will throw an error.
4+
* In case of success a parsed JSON body of the response will be returned,
5+
* unless the body contains a `location` field,
6+
* in that case the page will be redirected to that location.
7+
*
8+
* @param formId - ID of the form.
9+
* @param target - Target URL to POST to. Defaults to the current URL.
10+
* @returns {Promise<unknown>} - The response JSON.
11+
*/
12+
async function postJsonForm(formId, target = '') {
13+
const form = document.getElementById(formId);
14+
const formData = new FormData(form);
15+
const res = await fetch(target, {
16+
method: 'POST',
17+
credentials: 'include',
18+
headers: { 'accept': 'application/json', 'content-type': 'application/json' },
19+
body: JSON.stringify(Object.fromEntries(formData)),
20+
});
21+
if (res.status >= 400) {
22+
const error = await res.json();
23+
throw new Error(`${error.statusCode} - ${error.name}: ${error.message}`)
24+
} else if (res.status === 200 || res.status === 201) {
25+
const body = await res.json();
26+
if (body.location) {
27+
location.href = body.location;
28+
} else {
29+
return body;
30+
}
31+
}
32+
}
33+
34+
/**
35+
* Redirects the page to the given target with the key/value pairs of the JSON body as query parameters.
36+
* Controls will be deleted from the JSON to prevent very large URLs.
37+
* `false` values will be deleted to prevent incorrect serializations to "false".
38+
* @param json - JSON to convert.
39+
* @param target - URL to redirect to.
40+
*/
41+
function redirectJsonResponse(json, target) {
42+
// These would cause the URL to get very large, can be acquired later if needed
43+
delete json.controls;
44+
45+
// Remove false parameters since these would be converted to "false" strings
46+
for (const [key, val] of Object.entries(json)) {
47+
if (typeof val === 'boolean' && !val) {
48+
delete json[key];
49+
}
50+
}
51+
52+
const searchParams = new URLSearchParams(Object.entries(json));
53+
location.href = `${target}?${searchParams.toString()}`;
54+
}
55+
56+
/**
57+
* Adds a listener to the given form to catch the form submission and do an API call instead.
58+
* In case of an error, the inner text of the given error block will be updated with the message.
59+
* In case of success the callback function will be called.
60+
*
61+
* @param formId - ID of the form.
62+
* @param errorId - ID of the error block.
63+
* @param apiTarget - Target URL to send the POST request to. Defaults to the current URL.
64+
* @param callback - Callback function that will be called with the response JSON.
65+
*/
66+
async function addPostListener(formId, errorId, apiTarget, callback) {
67+
const form = document.getElementById(formId);
68+
const errorBlock = document.getElementById(errorId);
69+
70+
form.addEventListener('submit', async(event) => {
71+
event.preventDefault();
72+
73+
try {
74+
const json = await postJsonForm(formId, apiTarget);
75+
callback(json);
76+
} catch (error) {
77+
errorBlock.innerText = error.message;
78+
}
79+
});
80+
}
81+
82+
/**
83+
* Updates links on a page based on the controls received from the API.
84+
* @param url - API URL that will return the controls
85+
* @param controlMap - Key/value map with keys being element IDs and values being the control field names.
86+
*/
87+
async function addControlLinks(url, controlMap) {
88+
const json = await fetchJson(url);
89+
for (let [ id, control ] of Object.entries(controlMap)) {
90+
updateElement(id, json.controls[control], { href: true });
91+
}
92+
}
93+
94+
/**
95+
* Shows or hides the given element.
96+
* @param id - ID of the element.
97+
* @param visible - If it should be visible.
98+
*/
99+
function setVisibility(id, visible) {
100+
const element = document.getElementById(id);
101+
element.classList[visible ? 'remove' : 'add']('hidden');
102+
// Disable children of hidden elements,
103+
// such that the browser does not expect input for them
104+
for (const child of getDescendants(element)) {
105+
if ('disabled' in child)
106+
child.disabled = !visible;
107+
}
108+
}
109+
110+
/**
111+
* Obtains all children, grandchildren, etc. of the given element.
112+
* @param element - Element to get all descendants from.
113+
*/
114+
function getDescendants(element) {
115+
return [...element.querySelectorAll("*")];
116+
}
117+
118+
/**
119+
* Updates the inner text and href field of an element.
120+
* @param id - ID of the element.
121+
* @param text - Text to put in the field(s).
122+
* @param options - Indicates which fields should be updated.
123+
* Keys should be `innerText` and/or `href`, values should be booleans.
124+
*/
125+
function updateElement(id, text, options) {
126+
const element = document.getElementById(id);
127+
if (options.innerText) {
128+
element.innerText = text;
129+
}
130+
if (options.href) {
131+
element.href = text;
132+
}
133+
}
134+
135+
/**
136+
* Fetches JSON from the url and converts it to an object.
137+
* @param url - URL to fetch JSON from.
138+
*/
139+
async function fetchJson(url) {
140+
const res = await fetch(url, { headers: { accept: 'application/json' } });
141+
return res.json();
142+
}

‎templates/setup/response.html.ejs

+34-1
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,38 @@
1717
<% } %>
1818

1919
<% if (registration) { %>
20-
<%- include('../identity/email-password/register-response-partial.html.ejs', { authenticating: false }) %>
20+
<% if (createPod) { %>
21+
<h2>Your new Pod</h2>
22+
<p>
23+
Your new Pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>.
24+
<br>
25+
You can store your documents and data there.
26+
</p>
27+
<% } %>
28+
29+
<% if (createWebId) { %>
30+
<h2>Your new WebID</h2>
31+
<p>
32+
Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>.
33+
<br>
34+
You can use this identifier to interact with Solid pods and apps.
35+
</p>
36+
<% } %>
37+
38+
<% if (register) { %>
39+
<h2>Your new account</h2>
40+
<p>
41+
Via your email address <em><%= email %></em>,
42+
this server lets you log in to Solid apps
43+
with your WebID <a href="<%= webId %>" class="link"><%= webId %></a>
44+
</p>
45+
<% if (!createWebId) { %>
46+
<p>
47+
You will need to add the triple
48+
<code><%= `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${oidcIssuer}>.`%></code>
49+
to your existing WebID document <em><%= webId %></em>
50+
to indicate that you trust this server as a login provider.
51+
</p>
52+
<% } %>
53+
<% } %>
2154
<% } %>

‎templates/styles/main.css

+7
Original file line numberDiff line numberDiff line change
@@ -233,11 +233,18 @@ form ul.actions > li {
233233
margin-right: 1em;
234234
}
235235

236+
/* Directly hide hidden elements. */
237+
.hidden {
238+
display: none;
239+
}
240+
241+
/* Hide form elements with a sliding animation so users can track more easily what is happening. */
236242
form.loaded * {
237243
max-height: 1000px;
238244
transition: max-height .2s;
239245
}
240246
form .hidden {
247+
display: block;
241248
max-height: 0;
242249
overflow: hidden;
243250
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Operation } from '../../../../src/http/Operation';
2+
import { FixedInteractionHandler } from '../../../../src/identity/interaction/FixedInteractionHandler';
3+
import { readJsonStream } from '../../../../src/util/StreamUtil';
4+
5+
describe('A FixedInteractionHandler', (): void => {
6+
const json = { data: 'data' };
7+
const operation: Operation = { target: { path: 'http://example.com/test/' }} as any;
8+
const handler = new FixedInteractionHandler(json);
9+
10+
it('returns the given JSON as response.', async(): Promise<void> => {
11+
const response = await handler.handle({ operation });
12+
await expect(readJsonStream(response.data)).resolves.toEqual(json);
13+
expect(response.metadata.contentType).toBe('application/json');
14+
});
15+
});

‎test/unit/identity/interaction/HtmlViewHandler.test.ts

+19-1
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ import { readableToString } from '../../../../src/util/StreamUtil';
1010
import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
1111

1212
describe('An HtmlViewHandler', (): void => {
13+
const idpIndex = 'http://example.com/idp/';
14+
let index: InteractionRoute;
1315
let operation: Operation;
1416
let templates: Record<string, jest.Mocked<InteractionRoute>>;
1517
let templateEngine: TemplateEngine;
1618
let handler: HtmlViewHandler;
1719

1820
beforeEach(async(): Promise<void> => {
21+
index = {
22+
getPath: jest.fn().mockReturnValue(idpIndex),
23+
} as any;
24+
1925
operation = {
2026
method: 'GET',
2127
target: { path: 'http://example.com/idp/login/' },
@@ -32,7 +38,7 @@ describe('An HtmlViewHandler', (): void => {
3238
render: jest.fn().mockReturnValue(Promise.resolve('<html>')),
3339
};
3440

35-
handler = new HtmlViewHandler(templateEngine, templates);
41+
handler = new HtmlViewHandler(index, templateEngine, templates);
3642
});
3743

3844
it('rejects non-GET requests.', async(): Promise<void> => {
@@ -64,5 +70,17 @@ describe('An HtmlViewHandler', (): void => {
6470
const result = await handler.handle({ operation });
6571
expect(result.metadata.contentType).toBe(TEXT_HTML);
6672
await expect(readableToString(result.data)).resolves.toBe('<html>');
73+
expect(templateEngine.render).toHaveBeenCalledTimes(1);
74+
expect(templateEngine.render)
75+
.toHaveBeenLastCalledWith({ idpIndex, authenticating: false }, { templateFile: '/templates/login.html.ejs' });
76+
});
77+
78+
it('sets authenticating to true if there is an active interaction.', async(): Promise<void> => {
79+
const result = await handler.handle({ operation, oidcInteraction: {} as any });
80+
expect(result.metadata.contentType).toBe(TEXT_HTML);
81+
await expect(readableToString(result.data)).resolves.toBe('<html>');
82+
expect(templateEngine.render).toHaveBeenCalledTimes(1);
83+
expect(templateEngine.render)
84+
.toHaveBeenLastCalledWith({ idpIndex, authenticating: true }, { templateFile: '/templates/login.html.ejs' });
6785
});
6886
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
2+
import type {
3+
InteractionHandler,
4+
InteractionHandlerInput,
5+
} from '../../../../src/identity/interaction/InteractionHandler';
6+
import { LocationInteractionHandler } from '../../../../src/identity/interaction/LocationInteractionHandler';
7+
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
8+
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
9+
import { readJsonStream } from '../../../../src/util/StreamUtil';
10+
11+
describe('A LocationInteractionHandler', (): void => {
12+
const representation = new BasicRepresentation();
13+
const input: InteractionHandlerInput = {
14+
operation: {
15+
target: { path: 'http://example.com/target' },
16+
preferences: {},
17+
method: 'GET',
18+
body: new BasicRepresentation(),
19+
},
20+
};
21+
let source: jest.Mocked<InteractionHandler>;
22+
let handler: LocationInteractionHandler;
23+
24+
beforeEach(async(): Promise<void> => {
25+
source = {
26+
canHandle: jest.fn(),
27+
handle: jest.fn().mockResolvedValue(representation),
28+
} as any;
29+
30+
handler = new LocationInteractionHandler(source);
31+
});
32+
33+
it('calls the source canHandle function.', async(): Promise<void> => {
34+
await expect(handler.canHandle(input)).resolves.toBeUndefined();
35+
expect(source.canHandle).toHaveBeenCalledTimes(1);
36+
expect(source.canHandle).toHaveBeenLastCalledWith(input);
37+
38+
source.canHandle.mockRejectedValueOnce(new Error('bad input'));
39+
await expect(handler.canHandle(input)).rejects.toThrow('bad input');
40+
});
41+
42+
it('returns the source output.', async(): Promise<void> => {
43+
await expect(handler.handle(input)).resolves.toBe(representation);
44+
expect(source.handle).toHaveBeenCalledTimes(1);
45+
expect(source.handle).toHaveBeenLastCalledWith(input);
46+
});
47+
48+
it('returns a location object in case of redirect errors.', async(): Promise<void> => {
49+
const location = 'http://example.com/foo';
50+
source.handle.mockRejectedValueOnce(new FoundHttpError(location));
51+
52+
const response = await handler.handle(input);
53+
expect(response.metadata.identifier.value).toEqual(input.operation.target.path);
54+
await expect(readJsonStream(response.data)).resolves.toEqual({ location });
55+
});
56+
57+
it('rethrows non-redirect errors.', async(): Promise<void> => {
58+
source.handle.mockRejectedValueOnce(new NotFoundHttpError());
59+
60+
await expect(handler.handle(input)).rejects.toThrow(NotFoundHttpError);
61+
});
62+
});

0 commit comments

Comments
 (0)
Please sign in to comment.