241 lines
9.3 KiB
JavaScript
241 lines
9.3 KiB
JavaScript
(function (root, factory) {
|
||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||
else root.createLoginController = factory;
|
||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||
function validShareContinuation(value) {
|
||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5 };
|
||
const params = new URLSearchParams(value.slice(3));
|
||
const entries = Array.from(params.entries());
|
||
if (!entries.length) return './';
|
||
const invalid = entries.some(([name, content]) => (
|
||
!Object.prototype.hasOwnProperty.call(limits, name)
|
||
|| !content
|
||
|| content.length > limits[name]
|
||
));
|
||
if (invalid) return './';
|
||
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
|
||
const launch = params.get('launch');
|
||
const shared = params.get('shared');
|
||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||
return value;
|
||
}
|
||
|
||
const form = options.form;
|
||
const status = options.status;
|
||
const button = options.button;
|
||
const passkeyButton = options.passkeyButton;
|
||
const credentials = options.credentials;
|
||
const fetchImpl = options.fetchImpl;
|
||
const location = options.location;
|
||
const clearPrivateDeviceData = options.clearPrivateDeviceData;
|
||
const continuation = validShareContinuation(options.continuation);
|
||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||
const setTimeoutImpl = options.setTimeoutImpl || setTimeout;
|
||
const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout;
|
||
const requestTimeoutMs = options.requestTimeoutMs || 15000;
|
||
let timer = null;
|
||
let activeAttempt = false;
|
||
|
||
function setAttemptActive(active) {
|
||
activeAttempt = active;
|
||
button.disabled = active;
|
||
if (passkeyButton) passkeyButton.disabled = active || !credentials?.get;
|
||
}
|
||
|
||
async function requestWithDeadline(url, init) {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeoutImpl(() => {
|
||
const error = new Error('Sign-in request timed out');
|
||
error.name = 'TimeoutError';
|
||
controller.abort(error);
|
||
}, requestTimeoutMs);
|
||
try {
|
||
return await fetchImpl(url, { ...init, signal: controller.signal });
|
||
} finally {
|
||
clearTimeoutImpl(timeout);
|
||
}
|
||
}
|
||
|
||
function decodeBase64Url(value) {
|
||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||
+ '='.repeat((4 - String(value).length % 4) % 4);
|
||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||
}
|
||
|
||
function encodeBase64Url(value) {
|
||
if (value === null || value === undefined) return null;
|
||
const bytes = new Uint8Array(value);
|
||
let binary = '';
|
||
bytes.forEach(byte => { binary += String.fromCharCode(byte); });
|
||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||
}
|
||
|
||
function credentialJSON(credential) {
|
||
return {
|
||
id: credential.id,
|
||
type: credential.type,
|
||
rawId: encodeBase64Url(credential.rawId),
|
||
response: {
|
||
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
|
||
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
|
||
signature: encodeBase64Url(credential.response.signature),
|
||
userHandle: encodeBase64Url(credential.response.userHandle),
|
||
},
|
||
};
|
||
}
|
||
|
||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||
|
||
async function showReason(reason) {
|
||
if (reason === 'session-expired') {
|
||
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
||
return;
|
||
}
|
||
if (reason === 'session-idle') {
|
||
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
|
||
return;
|
||
}
|
||
if (reason !== 'session-revoked') return;
|
||
button.disabled = true;
|
||
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
|
||
try {
|
||
if (typeof clearPrivateDeviceData !== 'function') throw new Error('Private data purger unavailable.');
|
||
await clearPrivateDeviceData();
|
||
status.textContent = 'This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.';
|
||
button.disabled = false;
|
||
} catch (_error) {
|
||
status.textContent = 'This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.';
|
||
}
|
||
}
|
||
|
||
function showRetryCountdown(seconds) {
|
||
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
|
||
button.disabled = true;
|
||
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
|
||
timer = setIntervalImpl(() => {
|
||
remaining -= 1;
|
||
if (remaining <= 0) {
|
||
clearIntervalImpl(timer);
|
||
timer = null;
|
||
button.disabled = false;
|
||
status.textContent = 'You can try signing in again.';
|
||
return;
|
||
}
|
||
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
|
||
}, 1000);
|
||
}
|
||
|
||
async function submit(accessToken, deviceLabel = 'This device') {
|
||
if (activeAttempt) return false;
|
||
setAttemptActive(true);
|
||
status.textContent = 'Signing in…';
|
||
let response;
|
||
try {
|
||
response = await requestWithDeadline('api/v1/session', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
|
||
});
|
||
} catch (error) {
|
||
form.reset();
|
||
status.textContent = error?.name === 'TimeoutError'
|
||
? 'Sign-in timed out. Check your connection and try again.'
|
||
: 'Sign-in failed. Check your connection and try again.';
|
||
return false;
|
||
} finally {
|
||
setAttemptActive(false);
|
||
}
|
||
form.reset();
|
||
if (response.ok) {
|
||
location.replace(continuation);
|
||
return;
|
||
}
|
||
if (response.status === 429) {
|
||
showRetryCountdown(response.headers.get('Retry-After'));
|
||
return;
|
||
}
|
||
status.textContent = 'Sign-in failed. Check the token and try again.';
|
||
}
|
||
|
||
async function signInWithPasskey(deviceLabel = 'This device') {
|
||
if (!credentials?.get) {
|
||
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
|
||
return false;
|
||
}
|
||
if (activeAttempt) return false;
|
||
setAttemptActive(true);
|
||
status.textContent = 'Waiting for your passkey…';
|
||
try {
|
||
const optionsResponse = await requestWithDeadline('api/v1/passkeys/authentication/options', {
|
||
method: 'POST', headers: { Accept: 'application/json' },
|
||
});
|
||
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
|
||
const publicKey = await optionsResponse.json();
|
||
const challenge = publicKey.challenge;
|
||
publicKey.challenge = decodeBase64Url(publicKey.challenge);
|
||
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
|
||
...item, id: decodeBase64Url(item.id),
|
||
}));
|
||
const credential = await credentials.get({ publicKey });
|
||
const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', {
|
||
method: 'POST',
|
||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
challenge,
|
||
credential: credentialJSON(credential),
|
||
device_label: deviceLabel,
|
||
action: 'sign_in',
|
||
target: 'dashboard',
|
||
}),
|
||
});
|
||
if (!response.ok) throw new Error('Passkey verification failed');
|
||
location.replace(continuation);
|
||
return true;
|
||
} catch (error) {
|
||
status.textContent = error?.name === 'TimeoutError'
|
||
? 'Passkey sign-in timed out. Check your connection and try again.'
|
||
: 'Passkey sign-in was not completed. Try again or use the access token.';
|
||
return false;
|
||
} finally {
|
||
setAttemptActive(false);
|
||
}
|
||
}
|
||
|
||
if (passkeyButton) passkeyButton.disabled = !credentials?.get;
|
||
return { submit, signInWithPasskey, showReason };
|
||
}));
|
||
|
||
if (typeof document !== 'undefined') {
|
||
const form = document.getElementById('sign-in');
|
||
const status = document.getElementById('status');
|
||
const button = document.getElementById('submit-sign-in');
|
||
const passkeyButton = document.getElementById('passkey-sign-in');
|
||
const loginParams = new URLSearchParams(window.location.search);
|
||
const controller = createLoginController({
|
||
form,
|
||
status,
|
||
button,
|
||
passkeyButton,
|
||
credentials: window.navigator?.credentials,
|
||
fetchImpl: fetch.bind(window),
|
||
location: window.location,
|
||
continuation: loginParams.get('continue'),
|
||
clearPrivateDeviceData: window.stackchainPrivateDeviceData,
|
||
});
|
||
controller.showReason(loginParams.get('reason'));
|
||
form.addEventListener('submit', event => {
|
||
event.preventDefault();
|
||
const data = new FormData(form);
|
||
const accessToken = data.get('access_token');
|
||
const deviceLabel = data.get('device_label');
|
||
controller.submit(accessToken, deviceLabel);
|
||
});
|
||
passkeyButton?.addEventListener('click', () => {
|
||
const data = new FormData(form);
|
||
controller.signInWithPasskey(data.get('device_label'));
|
||
});
|
||
}
|