3 const th
= require('./template-helper');
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
15 function renderClientIdentifierProperties(hApp
) {
16 const properties
= hApp
.properties
|| {};
19 const { url
, summary
, logo
, name
} = properties
;
21 parts
.push('<span class="client-identifier">');
23 parts
.push(`<a href="${url[0]}">`);
25 if (summary
?.length
) {
26 imgTitle
= ` title="${summary[0]}"`;
30 if (typeof logo
[0] === 'string') {
32 alt
= 'Client Identifier Logo';
34 ({ value: src
, alt
} = logo
[0]);
36 parts
.push(`<img src="${src}" alt="${alt}"${imgTitle}>`);
39 parts
.push(properties
['name'][0]);
44 parts
.push('</span>');
45 return parts
.join('');
50 * @param {Object} clientIdentifier
51 * @param {Object[]} clientIdentifier.items
54 function renderClientIdentifier(clientIdentifier
) {
55 const hAppEntries
= clientIdentifier
?.items
|| [];
56 return hAppEntries
.map(renderClientIdentifierProperties
).join('');
61 * @param {String} profile
62 * @param {Boolean} selected
65 function renderProfileOption(profile
, selected
) {
66 return `<option value="${profile}"${selected ? ' selected' : ''}>${profile}</option>`;
71 * @param {String[]} availableProfiles
72 * @param {String} hintProfile
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}">`;
83 <legend>Select Profile</legend>
85 You may choose to identify to this client with a different profile.
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')}
96 * @param {ScopeDetails} scope
97 * @param {String} scope.scope
98 * @param {String} scope.description
99 * @param {String[]} scope.profiles
100 * @param {Boolean} checked
103 function renderScopeCheckboxLI(scope
, checked
) {
104 let scopeDescription
;
105 if (scope
.description
) {
107 <span class="description">${scope.description}</span>`;
109 scopeDescription
= '';
112 if (scope
.profiles
?.length
) {
113 profileClass
= ['profile-scope'].concat(scope
.profiles
.map((profile
) => th
.escapeCSS(profile
))).join(' ');
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}
125 function renderRequestedScopes(requestedScopes
) {
126 if (!requestedScopes
?.length
) {
132 <legend>Grants Requested By Client</legend>
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.
136 <ul class="scope" id="requested-scope-list">
137 ${requestedScopes.map((scopeDetails) => renderScopeCheckboxLI(scopeDetails, true)).join('\n')}
143 * @param {ScopeDetails[]} additionalScopes
146 function renderAdditionalScopes(additionalScopes
) {
151 <legend>Additional Grants</legend>`);
152 if (additionalScopes
?.length
) {
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.
158 <ul class="scope" id="additional-scope-list">
159 ${additionalScopes.map((scopeDetails) => renderScopeCheckboxLI(scopeDetails, false)).join('\n')}
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.
167 <label for="ad-hoc-scopes">Ad Hoc Scopes</label>
168 <input id="ad-hoc-scopes" name="ad_hoc_scopes" value="">
170 return parts
.join('');
177 function renderExpiration(requestedScopes
) {
178 const tokenableScopes
= requestedScopes
.filter((s
) => !['profile', 'email'].includes(s
));
179 if (!tokenableScopes
.length
) {
185 \t\t<legend>Expiration</legend>
187 \t\t\tBy default, tokens issued do not automatically expire, but a longevity can be enforced.
191 \t\t\t<summary>Set Expiration</summary>
193 \t\t\t\t${radioButton('expires', 'never', 'Never', true)}
196 \t\t\t\t${radioButton('expires', '1d', '1 Day')}
199 \t\t\t\t${radioButton('expires', '1w', '1 Week')}
202 \t\t\t\t${radioButton('expires', '1m', '1 Month')}
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>
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.
214 \t\t\t\t${radioButton('refresh', 'none', 'Not Refreshable', true)}
217 \t\t\t\t${radioButton('refresh', '1d', '1 Day')}
220 \t\t\t\t${radioButton('refresh', '1w', '1 Week')}
223 \t\t\t\t${radioButton('refresh', '1m', '1 Month')}
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>
233 function radioButton(name
, value
, label
, checked
= false, indent
= 0) {
234 const id
= `${name}-${value}`;
235 return th
.indented(indent
, [
237 `\t<input type="radio" name="${name}" id="${id}" value="${value}"${checked ? ' checked' : ''}>`,
238 `\t<label for="${id}">${label}</label>`,
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
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
|| {};
271 * Add requested scopes to index, if not already present,
272 * and de-associate requested scopes from profiles.
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
279 scopeIndex
[scopeName
] = { // eslint-disable-line security/detect-object-injection
286 // Divide scopes between requested and additional from profiles.
287 const requestedScopes
= scopes
.map((scope
) => ({
289 description: scopeIndex
[scope
].description
, // eslint-disable-line security/detect-object-injection
291 const additionalScopes
= Object
.keys(scopeIndex
)
292 .filter((scope
) => scopeIndex
[scope
].profiles
.length
) // eslint-disable-line security/detect-object-injection
295 description: scopeIndex
[scope
].description
, // eslint-disable-line security/detect-object-injection
296 profiles: scopeIndex
[scope
].profiles
, // eslint-disable-line security/detect-object-injection
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>.
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
),
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>
318 \t\t<input type="hidden" name="session" value="${session.persist}">
322 \t\tYou will be redirected to <a class="uri" name="${session.redirectUri}">${session.redirectUri}</a>.
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
346 module
.exports
= (ctx
, options
) => {
347 const htmlOptions
= {
348 pageTitle: `${options.manager.pageTitle} — Authorization Request`,
349 logoUrl: options
.manager
.logoUrl
,
350 footerEntries: options
.manager
.footerEntries
,
353 function queryAll(query, fn) {
354 const nodes = document.querySelectorAll(query);
355 console.log('query ' + query + ' selected ' + nodes.length);
356 return nodes.forEach(fn);
358 function profileSelected(element) {
359 const profileClass = CSS.escape(element.value);
360 console.log('new profile:', element.value, profileClass);
361 queryAll('.profile-scope input', (n) => n.setAttribute('disabled', true));
362 queryAll('.profile-scope', (n) => n.classList.add('disabled'));
363 const profileQuery = '.profile-scope.' + profileClass;
364 queryAll(profileQuery + ' input', (n) => n.setAttribute('disabled', false));
365 queryAll(profileQuery, (n) => n.classList.remove('disabled'));
368 return; // The escaped class selection does not seem to work, so ignore it all for now.
369 const profileSelect = document.getElementById('me');
370 profileSelect.onchange = () => profileSelected(profileSelect);
371 profileSelected(profileSelect);
376 const content
= mainContent(ctx
, options
);
377 return th
.htmlPage(0, ctx
, htmlOptions
, content
);