fix typo in ticket template causing extraneous navLink to be displayed
[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 type="text" 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\t${radioButton('expires', 'never', 'Never', true)}
193 \t\t\t\t${radioButton('expires', '1d', '1 Day')}
194 \t\t\t\t${radioButton('expires', '1w', '1 Week')}
195 \t\t\t\t${radioButton('expires', '1m', '1 Month')}
196 \t\t\t<div>
197 \t\t\t\t${radioButton('expires', 'custom', 'Other:')}
198 \t\t\t\t<input type="number" id="expires-seconds" name="expires-seconds">
199 \t\t\t\t<label for="expires-seconds">seconds</label>
200 \t\t\t</div>
201 \t\t\t<br>
202 \t\t\t<div>
203 \t\t\t\tTokens with expirations may be allowed to be renewed for a fresh token for an amount of time after they expire.
204 \t\t\t</div>
205 \t\t\t\t${radioButton('refresh', 'none', 'Not Refreshable', true)}
206 \t\t\t\t${radioButton('refresh', '1d', '1 Day')}
207 \t\t\t\t${radioButton('refresh', '1w', '1 Week')}
208 \t\t\t\t${radioButton('refresh', '1m', '1 Month')}
209 \t\t\t<div>
210 \t\t\t\t${radioButton('refresh', 'custom', 'Other:')}
211 \t\t\t\t<input type="number" id="refresh-seconds" name="refresh-seconds">
212 \t\t\t\t<label for="refresh-seconds">seconds</label>
213 \t\t\t</div>
214 \t\t</details>
215 \t</fieldset>`;
216 }
217
218 function radioButton(name, value, label, checked = false, indent = 0) {
219 const id = `${name}-${value}`;
220 return th.indented(indent, [
221 '<div>',
222 `\t<input type="radio" name="${name}" id="${id}" value="${value}"${checked ? ' checked' : ''}>`,
223 `\t<label for="${id}">${label}</label>`,
224 '</div>',
225 ]).join('');
226 }
227
228 /**
229 *
230 * @param {Object} ctx
231 * @param {Object[]} ctx.notifications
232 * @param {Object} ctx.session
233 * @param {String[]=} ctx.session.scope
234 * @param {URL=} ctx.session.me
235 * @param {String[]} ctx.session.profiles
236 * @param {ScopeIndex} ctx.session.scopeIndex
237 * @param {Object} ctx.session.clientIdentifier
238 * @param {Object[]} ctx.session.clientIdentifier.items
239 * @param {Object} ctx.session.clientIdentifier.items.properties
240 * @param {String[]=} ctx.session.clientIdentifier.items.properties.url
241 * @param {String[]=} ctx.session.clientIdentifier.items.properties.summary
242 * @param {String[]=} ctx.session.clientIdentifier.items.properties.logo
243 * @param {String[]=} ctx.session.clientIdentifier.items.properties.name
244 * @param {String} ctx.session.clientId
245 * @param {String} ctx.session.persist
246 * @param {String} ctx.session.redirectUri
247 * @param {Object} options
248 * @returns {String}
249 */
250 function mainContent(ctx, options) { // eslint-disable-line no-unused-vars
251 const session = ctx.session || {};
252 const hintedProfile = session.me?.href || session.profiles?.[0] || '';
253 const scopeIndex = session.scopeIndex || {};
254
255 /**
256 * Add requested scopes to index, if not already present,
257 * and de-associate requested scopes from profiles.
258 */
259 const scopes = session.scope || [];
260 scopes.forEach((scopeName) => {
261 if ((scopeName in scopeIndex)) {
262 scopeIndex[scopeName].profiles = []; // eslint-disable-line security/detect-object-injection
263 } else {
264 scopeIndex[scopeName] = { // eslint-disable-line security/detect-object-injection
265 description: '',
266 profiles: [],
267 };
268 }
269 });
270
271 // Divide scopes between requested and additional from profiles.
272 const requestedScopes = scopes.map((scope) => ({
273 scope,
274 description: scopeIndex[scope].description, // eslint-disable-line security/detect-object-injection
275 }));
276 const additionalScopes = Object.keys(scopeIndex)
277 .filter((scope) => scopeIndex[scope].profiles.length) // eslint-disable-line security/detect-object-injection
278 .map((scope) => ({
279 scope,
280 description: scopeIndex[scope].description, // eslint-disable-line security/detect-object-injection
281 profiles: scopeIndex[scope].profiles, // eslint-disable-line security/detect-object-injection
282 }));
283
284 return [
285 `<section class="information">
286 \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 ? hintedProfile : '(unspecified)'}</a>.
287 </section>
288 <section class="choices">
289 \t<form action="consent" method="POST" class="form-consent">`,
290 renderProfileFieldset(session.profiles, hintedProfile),
291 renderRequestedScopes(requestedScopes),
292 renderAdditionalScopes(additionalScopes),
293 renderExpiration(requestedScopes),
294 `
295 \t\t<br>
296 \t\t<fieldset>
297 \t\t\t<legend>Do you want to allow this?</legend>
298 \t\t\t<button type="submit" class="button-accept" name="accept" value="true">Accept</button>
299 \t\t\t<button type="submit" class="button-decline" name="accept" value="false">Decline</button>
300 \t\t</fieldset>
301 \t\t<input type="hidden" name="session" value="${session.persist}">
302 \t</form>
303 \t<br>
304 \t<div>
305 \t\tYou will be redirected to <a class="uri" id="${session.redirectUri ? session.redirectUri : 'unknown-redirect'}">${session.redirectUri}</a>.
306 \t</div>
307 </section>`,
308 ];
309 }
310
311 /**
312 *
313 * @param {Object} ctx
314 * @param {Object} ctx.session
315 * @param {String[]=} ctx.session.scope
316 * @param {URL=} ctx.session.me
317 * @param {String[]} ctx.session.profiles
318 * @param {ScopeIndex} ctx.session.scopeIndex
319 * @param {Object} ctx.session.clientIdentifier
320 * @param {String} ctx.session.clientId
321 * @param {String} ctx.session.persist
322 * @param {String} ctx.session.redirectUri
323 * @param {Object} options
324 * @param {Object} options.manager
325 * @param {String} options.manager.pageTitle
326 * @param {String} options.manager.footerEntries
327 * @returns {String}
328 */
329 module.exports = (ctx, options) => {
330 const pagePathLevel = 0;
331 const htmlOptions = {
332 pageTitle: `${options.manager.pageTitle} &mdash; Authorization Request`,
333 logoUrl: options.manager.logoUrl,
334 footerEntries: options.manager.footerEntries,
335 headElements: [
336 `<script>
337 function queryAll(query, fn) {
338 const nodes = document.querySelectorAll(query);
339 console.log('query ' + query + ' selected ' + nodes.length);
340 return nodes.forEach(fn);
341 }
342 function profileSelected(element) {
343 const profileClass = CSS.escape(element.value);
344 // queryAll('.profile-scope input', (n) => n.setAttribute('disabled', ''));
345 queryAll('.profile-scope', (n) => n.classList.add('disabled'));
346 const profileQuery = '.profile-scope.' + profileClass;
347 // queryAll(profileQuery + ' input', (n) => n.removeAttribute('disabled'));
348 queryAll(profileQuery, (n) => n.classList.remove('disabled'));
349 }
350 function onLoad() {
351 const profileSelect = document.getElementById('me');
352 profileSelect.onchange = () => profileSelected(profileSelect);
353 profileSelected(profileSelect);
354 }
355 window.onload = onLoad;
356 </script>`,
357 ],
358 };
359 th.navLinks(pagePathLevel, ctx, htmlOptions);
360 sessionNavLinks(pagePathLevel, ctx, htmlOptions);
361 const content = mainContent(ctx, options);
362 return th.htmlPage(pagePathLevel, ctx, htmlOptions, content);
363 };