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