update depedencies, changes to support updated authentication-module
[squeep-indie-auther] / src / template / authorization-request-html.js
1 'use strict';
2
3 const th = require('./template-helper');
4 const { sessionNavLinks } = require('@squeep/authentication-module');
5
6 /**
7 * @param {Object} hApp
8 * @param {Object} hApp.properties
9 * @param {String[]=} hApp.properties.url
10 * @param {String[]=} hApp.properties.summary
11 * @param {String[]=} hApp.properties.logo
12 * @param {String[]=} hApp.properties.name
13 * @returns {String}
14 */
15 function renderClientIdentifierProperties(hApp) {
16 const properties = hApp.properties || {};
17 const parts = [];
18 let imgTitle = '';
19 const { url, summary, logo, name } = properties;
20
21 parts.push('<span class="client-identifier">');
22 if (url?.length) {
23 parts.push(`<a href="${url[0]}">`);
24 }
25 if (summary?.length) {
26 imgTitle = ` title="${summary[0]}"`;
27 }
28 if (logo?.length) {
29 let src, alt;
30 if (typeof logo[0] === 'string') {
31 src = logo[0];
32 alt = 'Client Identifier Logo';
33 } else {
34 ({ value: src, alt } = logo[0]);
35 }
36 parts.push(`<img src="${src}" alt="${alt}"${imgTitle}>`);
37 }
38 if (name?.length) {
39 parts.push(properties['name'][0]);
40 }
41 if (url?.length) {
42 parts.push('</a>');
43 }
44 parts.push('</span>');
45 return parts.join('');
46 }
47
48
49 /**
50 * @param {Object} clientIdentifier
51 * @param {Object[]} clientIdentifier.items
52 * @returns {String}
53 */
54 function renderClientIdentifier(clientIdentifier) {
55 const hAppEntries = clientIdentifier?.items || [];
56 return hAppEntries.map(renderClientIdentifierProperties).join('');
57 }
58
59
60 /**
61 * @param {String} profile
62 * @param {Boolean} selected
63 * @returns {String}
64 */
65 function renderProfileOption(profile, selected) {
66 return `<option value="${profile}"${selected ? ' selected' : ''}>${profile}</option>`;
67 }
68
69
70 /**
71 * @param {String[]} availableProfiles
72 * @param {String} hintProfile
73 * @returns {String}
74 */
75 function renderProfileFieldset(availableProfiles, hintProfile) {
76 if (!availableProfiles || availableProfiles.length <= 1) {
77 const profile = availableProfiles?.[0] || hintProfile;
78 return `<input type="hidden" name="me" value="${profile}">`;
79 }
80 return `
81 <br>
82 <fieldset>
83 <legend>Select Profile</legend>
84 <div>
85 You may choose to identify to this client with a different profile.
86 </div>
87 <label for="me">Choose your identifying profile</label>
88 <select class="uri" name="me" id="me">
89 ${availableProfiles.map((profile) => renderProfileOption(profile, profile === hintProfile)).join('\n')}
90 </select>
91 </fieldset>`;
92 }
93
94
95 /**
96 * @param {ScopeDetails} scope
97 * @param {String} scope.scope
98 * @param {String} scope.description
99 * @param {String[]} scope.profiles
100 * @param {Boolean} checked
101 * @returns {String}
102 */
103 function renderScopeCheckboxLI(scope, checked) {
104 let scopeDescription;
105 if (scope.description) {
106 scopeDescription = `
107 <span class="description">${scope.description}</span>`;
108 } else {
109 scopeDescription = '';
110 }
111 let profileClass;
112 if (scope.profiles?.length) {
113 profileClass = ['profile-scope'].concat(scope.profiles).join(' ');
114 } else {
115 profileClass = '';
116 }
117 return `
118 <li class="${profileClass}">
119 <input type="checkbox" id="scope_${scope.scope}" name="accepted_scopes" value="${scope.scope}"${checked ? ' checked' : ''}>
120 <label for="scope_${scope.scope}">${scope.scope}</label>${scopeDescription}
121 </li>`;
122 }
123
124
125 function renderRequestedScopes(requestedScopes) {
126 if (!requestedScopes?.length) {
127 return '';
128 }
129 return `
130 <br>
131 <fieldset>
132 <legend>Grants Requested By Client</legend>
133 <div>
134 In addition to identifying you by your profile URL, this client has requested the following additional scope thingies. You may disable any of them here.
135 </div>
136 <ul class="scope" id="requested-scope-list">
137 ${requestedScopes.map((scopeDetails) => renderScopeCheckboxLI(scopeDetails, true)).join('\n')}
138 </ul>
139 </fieldset>`;
140 }
141
142 /**
143 * @param {ScopeDetails[]} additionalScopes
144 * @returns {String}
145 */
146 function renderAdditionalScopes(additionalScopes) {
147 const parts = [];
148 parts.push(`
149 <br>
150 <fieldset>
151 <legend>Additional Grants</legend>`);
152 if (additionalScopes?.length) {
153 parts.push(`
154 <div>
155 Your profile has been configured to offer scopes which were not explicitly requested by the client application.
156 Select any you would like to include.
157 </div>
158 <ul class="scope" id="additional-scope-list">
159 ${additionalScopes.map((scopeDetails) => renderScopeCheckboxLI(scopeDetails, false)).join('\n')}
160 </ul>
161 <br>`);
162 }
163 parts.push(`
164 <div>
165 You may also specify a space-separated list of any additional ad hoc scopes you would like to associate with this authorization request, which were not explicitly requested by the client application.
166 </div>
167 <label for="ad-hoc-scopes">Ad Hoc Scopes</label>
168 <input id="ad-hoc-scopes" name="ad_hoc_scopes" value="">
169 </fieldset>`);
170 return parts.join('');
171 }
172
173
174 /**
175 *
176 */
177 function renderExpiration(requestedScopes) {
178 const tokenableScopes = requestedScopes.filter((s) => !['profile', 'email'].includes(s));
179 if (!tokenableScopes.length) {
180 return '';
181 }
182 return `
183 \t<br>
184 \t<fieldset>
185 \t\t<legend>Expiration</legend>
186 \t\t<div>
187 \t\t\tBy default, tokens issued do not automatically expire, but a longevity can be enforced.
188 \t\t</div>
189 \t\t<br>
190 \t\t<details>
191 \t\t\t<summary>Set Expiration</summary>
192 \t\t\t<div>
193 \t\t\t\t${radioButton('expires', 'never', 'Never', true)}
194 \t\t\t</div>
195 \t\t\t<div>
196 \t\t\t\t${radioButton('expires', '1d', '1 Day')}
197 \t\t\t</div>
198 \t\t\t<div>
199 \t\t\t\t${radioButton('expires', '1w', '1 Week')}
200 \t\t\t</div>
201 \t\t\t<div>
202 \t\t\t\t${radioButton('expires', '1m', '1 Month')}
203 \t\t\t</div>
204 \t\t\t<div>
205 \t\t\t\t${radioButton('expires', 'custom', 'Other:')}
206 \t\t\t\t<input type="number" id="expires-seconds" name="expires-seconds">
207 \t\t\t\t<label for="expires-seconds">seconds</label>
208 \t\t\t</div>
209 \t\t\t<br>
210 \t\t\t<div>
211 \t\t\t\tTokens with expirations may be allowed to be renewed for a fresh token for an amount of time after they expire.
212 \t\t\t</div>
213 \t\t\t<div>
214 \t\t\t\t${radioButton('refresh', 'none', 'Not Refreshable', true)}
215 \t\t\t</div>
216 \t\t\t<div>
217 \t\t\t\t${radioButton('refresh', '1d', '1 Day')}
218 \t\t\t</div>
219 \t\t\t<div>
220 \t\t\t\t${radioButton('refresh', '1w', '1 Week')}
221 \t\t\t</div>
222 \t\t\t<div>
223 \t\t\t\t${radioButton('refresh', '1m', '1 Month')}
224 \t\t\t<div>
225 \t\t\t\t${radioButton('refresh', 'custom', 'Other:')}
226 \t\t\t\t<input type="number" id="refresh-seconds" name="refresh-seconds">
227 \t\t\t\t<label for="refresh-seconds">seconds</label>
228 \t\t\t </div>
229 \t\t</details>
230 \t</fieldset>`;
231 }
232
233 function radioButton(name, value, label, checked = false, indent = 0) {
234 const id = `${name}-${value}`;
235 return th.indented(indent, [
236 '<div>',
237 `\t<input type="radio" name="${name}" id="${id}" value="${value}"${checked ? ' checked' : ''}>`,
238 `\t<label for="${id}">${label}</label>`,
239 '</div>',
240 ]).join('');
241 }
242
243 /**
244 *
245 * @param {Object} ctx
246 * @param {Object[]} ctx.notifications
247 * @param {Object} ctx.session
248 * @param {String[]=} ctx.session.scope
249 * @param {URL=} ctx.session.me
250 * @param {String[]} ctx.session.profiles
251 * @param {ScopeIndex} ctx.session.scopeIndex
252 * @param {Object} ctx.session.clientIdentifier
253 * @param {Object[]} ctx.session.clientIdentifier.items
254 * @param {Object} ctx.session.clientIdentifier.items.properties
255 * @param {String[]=} ctx.session.clientIdentifier.items.properties.url
256 * @param {String[]=} ctx.session.clientIdentifier.items.properties.summary
257 * @param {String[]=} ctx.session.clientIdentifier.items.properties.logo
258 * @param {String[]=} ctx.session.clientIdentifier.items.properties.name
259 * @param {String} ctx.session.clientId
260 * @param {String} ctx.session.persist
261 * @param {String} ctx.session.redirectUri
262 * @param {Object} options
263 * @returns {String}
264 */
265 function mainContent(ctx, options) { // eslint-disable-line no-unused-vars
266 const session = ctx.session || {};
267 const hintedProfile = session.me?.href || session.profiles?.[0] || '';
268 const scopeIndex = session.scopeIndex || {};
269
270 /**
271 * Add requested scopes to index, if not already present,
272 * and de-associate requested scopes from profiles.
273 */
274 const scopes = session.scope || [];
275 scopes.forEach((scopeName) => {
276 if ((scopeName in scopeIndex)) {
277 scopeIndex[scopeName].profiles = []; // eslint-disable-line security/detect-object-injection
278 } else {
279 scopeIndex[scopeName] = { // eslint-disable-line security/detect-object-injection
280 description: '',
281 profiles: [],
282 };
283 }
284 });
285
286 // Divide scopes between requested and additional from profiles.
287 const requestedScopes = scopes.map((scope) => ({
288 scope,
289 description: scopeIndex[scope].description, // eslint-disable-line security/detect-object-injection
290 }));
291 const additionalScopes = Object.keys(scopeIndex)
292 .filter((scope) => scopeIndex[scope].profiles.length) // eslint-disable-line security/detect-object-injection
293 .map((scope) => ({
294 scope,
295 description: scopeIndex[scope].description, // eslint-disable-line security/detect-object-injection
296 profiles: scopeIndex[scope].profiles, // eslint-disable-line security/detect-object-injection
297 }));
298
299 return [
300 `<section class="information">
301 \tThe application client
302 \t${renderClientIdentifier(session.clientIdentifier)}
303 \tat <a class="uri" name="${session.clientId}">${session.clientId}</a> would like to identify you as <a class="uri" name="${hintedProfile}">${hintedProfile}</a>.
304 </section>
305 <section class="choices">
306 \t<form action="consent" method="POST" class="form-consent">`,
307 renderProfileFieldset(session.profiles, hintedProfile),
308 renderRequestedScopes(requestedScopes),
309 renderAdditionalScopes(additionalScopes),
310 renderExpiration(requestedScopes),
311 `
312 \t\t<br>
313 \t\t<fieldset>
314 \t\t\t<legend>Do you want to allow this?</legend>
315 \t\t\t<button class="button-accept" name="accept" value="true">Accept</button>
316 \t\t\t<button class="button-decline" name="accept" value="false">Decline</button>
317 \t\t</fieldset>
318 \t\t<input type="hidden" name="session" value="${session.persist}">
319 \t</form>
320 \t<br>
321 \t<div>
322 \t\tYou will be redirected to <a class="uri" name="${session.redirectUri}">${session.redirectUri}</a>.
323 \t</div>
324 </section>`,
325 ];
326 }
327
328 /**
329 *
330 * @param {Object} ctx
331 * @param {Object} ctx.session
332 * @param {String[]=} ctx.session.scope
333 * @param {URL=} ctx.session.me
334 * @param {String[]} ctx.session.profiles
335 * @param {ScopeIndex} ctx.session.scopeIndex
336 * @param {Object} ctx.session.clientIdentifier
337 * @param {String} ctx.session.clientId
338 * @param {String} ctx.session.persist
339 * @param {String} ctx.session.redirectUri
340 * @param {Object} options
341 * @param {Object} options.manager
342 * @param {String} options.manager.pageTitle
343 * @param {String} options.manager.footerEntries
344 * @returns {String}
345 */
346 module.exports = (ctx, options) => {
347 const pagePathLevel = 0;
348 const htmlOptions = {
349 pageTitle: `${options.manager.pageTitle} &mdash; Authorization Request`,
350 logoUrl: options.manager.logoUrl,
351 footerEntries: options.manager.footerEntries,
352 headElements: [
353 `<script>
354 function queryAll(query, fn) {
355 const nodes = document.querySelectorAll(query);
356 console.log('query ' + query + ' selected ' + nodes.length);
357 return nodes.forEach(fn);
358 }
359 function profileSelected(element) {
360 const profileClass = CSS.escape(element.value);
361 // queryAll('.profile-scope input', (n) => n.setAttribute('disabled', ''));
362 queryAll('.profile-scope', (n) => n.classList.add('disabled'));
363 const profileQuery = '.profile-scope.' + profileClass;
364 // queryAll(profileQuery + ' input', (n) => n.removeAttribute('disabled'));
365 queryAll(profileQuery, (n) => n.classList.remove('disabled'));
366 }
367 function onLoad() {
368 const profileSelect = document.getElementById('me');
369 profileSelect.onchange = () => profileSelected(profileSelect);
370 profileSelected(profileSelect);
371 }
372 window.onload = onLoad;
373 </script>`,
374 ],
375 };
376 th.navLinks(pagePathLevel, ctx, htmlOptions);
377 sessionNavLinks(pagePathLevel, ctx, htmlOptions);
378 const content = mainContent(ctx, options);
379 return th.htmlPage(pagePathLevel, ctx, htmlOptions, content);
380 };