Merge branch 'main' into delete-account-improvements
This commit is contained in:
@@ -6,3 +6,4 @@ import "controllers"
|
||||
|
||||
import "lexxy"
|
||||
import "@rails/actiontext"
|
||||
import "lib/action_pack/passkey"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
|
||||
|
||||
export default class extends BridgeComponent {
|
||||
static component = "stamp"
|
||||
static values = { scopeSelector: { type: String, default: "body" } }
|
||||
|
||||
connect() {
|
||||
super.connect()
|
||||
|
||||
if (this.element.closest(this.scopeSelectorValue)) {
|
||||
this.notifyBridgeOfConnect()
|
||||
this.#observeStamp()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
super.disconnect()
|
||||
this.notifyBridgeOfDisconnect()
|
||||
this.stampObserver?.disconnect()
|
||||
}
|
||||
|
||||
notifyBridgeOfConnect() {
|
||||
const bridgeElement = this.bridgeElement
|
||||
|
||||
this.send("connect", {
|
||||
title: bridgeElement.title,
|
||||
description: bridgeElement.bridgeAttribute("description")
|
||||
})
|
||||
}
|
||||
|
||||
notifyBridgeOfDisconnect() {
|
||||
this.send("disconnect")
|
||||
}
|
||||
|
||||
#observeStamp() {
|
||||
this.stampObserver = new MutationObserver(() => {
|
||||
this.notifyBridgeOfConnect()
|
||||
})
|
||||
|
||||
this.stampObserver.observe(this.element, {
|
||||
attributes: true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { Turbo } from "@hotwired/turbo-rails"
|
||||
|
||||
export default class extends Controller {
|
||||
clearCache() {
|
||||
Turbo.offline.clearCache()
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default class extends Controller {
|
||||
|
||||
submit() {
|
||||
if (this.inputTarget.disabled) return
|
||||
this.element.submit()
|
||||
this.element.requestSubmit()
|
||||
this.inputTarget.disabled = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { label: String }
|
||||
static targets = [ "referrerBackLink" ]
|
||||
|
||||
rememberLocation() {
|
||||
sessionStorage.setItem("referrerUrl", window.location.href)
|
||||
sessionStorage.setItem("referrerLabel", this.labelValue)
|
||||
}
|
||||
|
||||
backIfSamePath(event) {
|
||||
@@ -16,6 +20,20 @@ export default class extends Controller {
|
||||
Turbo.visit(this.#referrerUrl)
|
||||
}
|
||||
}
|
||||
|
||||
referrerBackLinkTargetConnected(link) {
|
||||
if (!this.#referrerUrl || !this.#referrerLabel) { return }
|
||||
|
||||
const stripTrailingSlash = path => path.replace(/\/$/, "")
|
||||
const allowedPaths = (link.dataset.turboNavigationAllowedReferrerPaths || "").split(",").map(stripTrailingSlash)
|
||||
const referrerPath = stripTrailingSlash(new URL(this.#referrerUrl).pathname)
|
||||
if (!allowedPaths.includes(referrerPath)) { return }
|
||||
|
||||
link.href = this.#referrerUrl
|
||||
const strong = link.querySelector("strong")
|
||||
if (strong) { strong.textContent = `Back to ${this.#referrerLabel}` }
|
||||
}
|
||||
|
||||
get #referrerPath() {
|
||||
if (!this.#referrerUrl) return null
|
||||
return new URL(this.#referrerUrl).pathname
|
||||
@@ -24,4 +42,8 @@ export default class extends Controller {
|
||||
get #referrerUrl() {
|
||||
return sessionStorage.getItem("referrerUrl")
|
||||
}
|
||||
|
||||
get #referrerLabel() {
|
||||
return sessionStorage.getItem("referrerLabel")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
#showFileName() {
|
||||
this.fileNameTarget.innerHTML = this.#file.name
|
||||
this.fileNameTarget.innerText = this.#file.name
|
||||
this.fileNameTarget.removeAttribute("hidden")
|
||||
this.placeholderTarget.setAttribute("hidden", true)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ BridgeElement.prototype.getButton = function() {
|
||||
title: this.title,
|
||||
icon: this.getIcon(),
|
||||
displayTitle: this.getDisplayTitle(),
|
||||
displayAsPrimaryAction: this.getDisplayAsPrimaryAction()
|
||||
displayAsPrimaryAction: this.getDisplayAsPrimaryAction(),
|
||||
slot: this.getSlot()
|
||||
}
|
||||
}
|
||||
|
||||
BridgeElement.prototype.getIcon = function() {
|
||||
const url = this.bridgeAttribute(`icon-url`)
|
||||
const url = this.bridgeAttribute("icon-url")
|
||||
|
||||
if (url) {
|
||||
return { url }
|
||||
@@ -20,9 +21,13 @@ BridgeElement.prototype.getIcon = function() {
|
||||
}
|
||||
|
||||
BridgeElement.prototype.getDisplayTitle = function() {
|
||||
return !!this.bridgeAttribute(`display-title`)
|
||||
return !!this.bridgeAttribute("display-title")
|
||||
}
|
||||
|
||||
BridgeElement.prototype.getDisplayAsPrimaryAction = function() {
|
||||
return !!this.bridgeAttribute(`display-as-primary-action`)
|
||||
return !!this.bridgeAttribute("display-as-primary-action")
|
||||
}
|
||||
|
||||
BridgeElement.prototype.getSlot = function () {
|
||||
return this.bridgeAttribute("slot") ?? "right"
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
import "initializers/current"
|
||||
import "initializers/bridge/bridge_element"
|
||||
import "initializers/offline"
|
||||
import "initializers/lexxy_markdown_paste"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
document.addEventListener("lexxy:insert-markdown", (event) => {
|
||||
event.detail.addBlockSpacing()
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Turbo } from "@hotwired/turbo-rails"
|
||||
|
||||
if (Current.user) {
|
||||
Turbo.offline.start("/service-worker.js", {
|
||||
scope: "/",
|
||||
native: true,
|
||||
preload: /\/assets\//
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// JS companion for the ActionPack::Passkey Ruby helpers.
|
||||
//
|
||||
// Binds click handlers to passkey buttons and manages the WebAuthn ceremony
|
||||
// lifecycle (challenge refresh, credential creation/authentication, form submission).
|
||||
//
|
||||
// Expected data attributes:
|
||||
// [data-passkey="create"] — triggers the registration ceremony
|
||||
// [data-passkey="sign_in"] — triggers the authentication ceremony
|
||||
// [data-passkey-mediation="conditional"] — on a <form>, enables autofill-assisted sign in
|
||||
// [data-passkey-errors] — container whose data-passkey-error-state is set on failure
|
||||
// [data-passkey-error="error|cancelled"] — children shown/hidden via CSS based on error state
|
||||
// [data-passkey-field="..."] — hidden fields populated before form submission
|
||||
//
|
||||
// Custom events (all bubble):
|
||||
// passkey:start — ceremony begun
|
||||
// passkey:success — credential obtained, form about to submit
|
||||
// passkey:error — ceremony failed; detail: { error, cancelled }
|
||||
//
|
||||
// Meta tags (rendered by the Ruby form helpers):
|
||||
// <meta name="passkey-creation-options"> — JSON WebAuthn creation options
|
||||
// <meta name="passkey-request-options"> — JSON WebAuthn request options
|
||||
// <meta name="passkey-challenge-url"> — endpoint to refresh the challenge nonce
|
||||
|
||||
import { register, authenticate } from "lib/action_pack/webauthn"
|
||||
|
||||
let listeners
|
||||
let currentDocument
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setup)
|
||||
document.addEventListener("turbo:load", setup)
|
||||
document.addEventListener("turbo:before-cache", teardown)
|
||||
|
||||
// Set error state on the nearest [data-passkey-errors] container.
|
||||
// The app's CSS is responsible for showing/hiding children based on
|
||||
// the data-passkey-error-state attribute ("error" or "cancelled").
|
||||
document.addEventListener("passkey:error", ({ target, detail: { cancelled } }) => {
|
||||
const container = target.closest("[data-passkey-errors]")
|
||||
|
||||
if (container) {
|
||||
container.dataset.passkeyErrorState = cancelled ? "cancelled" : "error"
|
||||
}
|
||||
})
|
||||
|
||||
// Bind click handlers to passkey buttons and attempt conditional mediation.
|
||||
// Guards against duplicate setup.
|
||||
function setup() {
|
||||
if (currentDocument !== document.documentElement) {
|
||||
currentDocument = document.documentElement
|
||||
|
||||
listeners?.abort()
|
||||
listeners = new AbortController()
|
||||
|
||||
for (const button of document.querySelectorAll('[data-passkey="create"]')) {
|
||||
button.addEventListener("click", () => createPasskey(button), { signal: listeners.signal })
|
||||
}
|
||||
|
||||
for (const button of document.querySelectorAll('[data-passkey="sign_in"]')) {
|
||||
button.addEventListener("click", () => signInWithPasskey(button), { signal: listeners.signal })
|
||||
}
|
||||
|
||||
attemptConditionalMediation()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset transient DOM state and unbind event handlers to prevent leaks and duplicate handlers.
|
||||
function teardown() {
|
||||
currentDocument = null
|
||||
listeners?.abort()
|
||||
|
||||
for (const button of document.querySelectorAll('[data-passkey][disabled]')) {
|
||||
button.disabled = false
|
||||
}
|
||||
|
||||
for (const container of document.querySelectorAll("[data-passkey-errors]")) {
|
||||
delete container.dataset.passkeyErrorState
|
||||
}
|
||||
}
|
||||
|
||||
// Run the WebAuthn registration ceremony: refresh the challenge, prompt the
|
||||
// browser to create a credential, fill the form's hidden fields, and submit.
|
||||
async function createPasskey(button) {
|
||||
const form = button.closest("form")
|
||||
|
||||
if (form) {
|
||||
button.disabled = true
|
||||
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||
|
||||
try {
|
||||
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
|
||||
|
||||
const creationOptions = getCreationOptions()
|
||||
if (!creationOptions) throw new Error("Missing passkey creation options")
|
||||
|
||||
await refreshChallenge(creationOptions)
|
||||
const passkey = await register(creationOptions)
|
||||
|
||||
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||
fillCreateForm(form, passkey)
|
||||
form.submit()
|
||||
} catch (error) {
|
||||
button.disabled = false
|
||||
|
||||
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
|
||||
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function passkeysAvailable() {
|
||||
return !!window.PublicKeyCredential
|
||||
}
|
||||
|
||||
// Read WebAuthn creation options from the <meta> tag rendered by
|
||||
// +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing.
|
||||
function getCreationOptions() {
|
||||
return getOptions("passkey-creation-options")
|
||||
}
|
||||
|
||||
// Parse and return the JSON content of a <meta> tag by name.
|
||||
function getOptions(name) {
|
||||
const meta = document.querySelector(`meta[name="${name}"]`)
|
||||
|
||||
if (meta) {
|
||||
return JSON.parse(meta.content)
|
||||
}
|
||||
}
|
||||
|
||||
// POST to the challenge endpoint to get a fresh nonce, preventing replay attacks
|
||||
// when the page has been open for a while before the user initiates the ceremony.
|
||||
async function refreshChallenge(options) {
|
||||
const url = document.querySelector('meta[name="passkey-challenge-url"]')?.content
|
||||
if (!url) throw new Error("Missing passkey challenge URL")
|
||||
const token = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-CSRF-Token": token,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error("Failed to refresh challenge")
|
||||
|
||||
const { challenge } = await response.json()
|
||||
options.challenge = challenge
|
||||
}
|
||||
|
||||
// Populate the registration form's hidden fields with the credential response.
|
||||
// Clones the transports template input for each reported transport.
|
||||
function fillCreateForm(form, passkey) {
|
||||
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
|
||||
form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object
|
||||
|
||||
const template = form.querySelector('[data-passkey-field="transports"]')
|
||||
for (const transport of passkey.transports) {
|
||||
const input = template.cloneNode()
|
||||
input.value = transport
|
||||
template.before(input)
|
||||
}
|
||||
template.remove()
|
||||
}
|
||||
|
||||
// Run the WebAuthn authentication ceremony: refresh the challenge, prompt the
|
||||
// browser to sign with an existing credential, fill the form, and submit.
|
||||
async function signInWithPasskey(button) {
|
||||
const form = button.closest("form")
|
||||
|
||||
if (form) {
|
||||
button.disabled = true
|
||||
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||
|
||||
try {
|
||||
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
|
||||
|
||||
const requestOptions = getRequestOptions()
|
||||
if (!requestOptions) throw new Error("Missing passkey request options")
|
||||
|
||||
await refreshChallenge(requestOptions)
|
||||
const passkey = await authenticate(requestOptions)
|
||||
|
||||
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||
fillSignInForm(form, passkey)
|
||||
form.submit()
|
||||
} catch (error) {
|
||||
button.disabled = false
|
||||
|
||||
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
|
||||
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read WebAuthn request options from the <meta> tag rendered by
|
||||
// +passkey_request_options_meta_tag+. Returns undefined if the tag is missing.
|
||||
function getRequestOptions() {
|
||||
return getOptions("passkey-request-options")
|
||||
}
|
||||
|
||||
// Populate the authentication form's hidden fields with the assertion response.
|
||||
function fillSignInForm(form, passkey) {
|
||||
form.querySelector('[data-passkey-field="id"]').value = passkey.id
|
||||
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
|
||||
form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data
|
||||
form.querySelector('[data-passkey-field="signature"]').value = passkey.signature
|
||||
}
|
||||
|
||||
// Start the conditional mediation (autofill) ceremony if the page opts in with
|
||||
// a form[data-passkey-mediation="conditional"] and the browser supports it.
|
||||
// Unlike the button-driven ceremonies, this runs automatically on page load.
|
||||
async function attemptConditionalMediation() {
|
||||
if (await conditionalMediationAvailable()) {
|
||||
const form = document.querySelector('form[data-passkey-mediation="conditional"]')
|
||||
form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||
|
||||
const requestOptions = getRequestOptions()
|
||||
|
||||
try {
|
||||
await refreshChallenge(requestOptions)
|
||||
|
||||
const passkey = await authenticate(requestOptions, { mediation: "conditional" })
|
||||
|
||||
form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||
fillSignInForm(form, passkey)
|
||||
form.submit()
|
||||
} catch (error) {
|
||||
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
|
||||
form.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check all preconditions for conditional mediation: the page has opted in,
|
||||
// request options are present, the browser supports passkeys, and the browser
|
||||
// supports the conditional mediation UI (autofill).
|
||||
async function conditionalMediationAvailable() {
|
||||
return isConditionalMediationFormPresent() &&
|
||||
getRequestOptions() &&
|
||||
passkeysAvailable() &&
|
||||
await window.PublicKeyCredential.isConditionalMediationAvailable?.()
|
||||
}
|
||||
|
||||
function isConditionalMediationFormPresent() {
|
||||
return !!document.querySelector('form[data-passkey-mediation="conditional"]')
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Thin wrapper around the browser WebAuthn API (navigator.credentials).
|
||||
//
|
||||
// Handles the base64url ↔ ArrayBuffer conversions required by the spec so
|
||||
// callers can work with plain JSON objects from the server-rendered meta tags.
|
||||
|
||||
// Call navigator.credentials.create() with the given creation options.
|
||||
// Returns { client_data_json, attestation_object, transports } with all
|
||||
// binary fields encoded as base64url strings ready for form submission.
|
||||
export async function register(options) {
|
||||
const publicKey = prepareCreationOptions(options)
|
||||
const credential = await navigator.credentials.create({ publicKey })
|
||||
|
||||
return {
|
||||
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
|
||||
attestation_object: bufferToBase64url(credential.response.attestationObject),
|
||||
transports: credential.response.getTransports?.() || []
|
||||
}
|
||||
}
|
||||
|
||||
// Call navigator.credentials.get() with the given request options.
|
||||
// Accepts an optional signal (AbortSignal) and mediation hint ("conditional"
|
||||
// for autofill UI). Returns { id, client_data_json, authenticator_data, signature }
|
||||
// with binary fields encoded as base64url strings.
|
||||
export async function authenticate(options, { signal, mediation } = {}) {
|
||||
const publicKey = prepareRequestOptions(options)
|
||||
const credential = await navigator.credentials.get({ publicKey, signal, mediation })
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
|
||||
authenticator_data: bufferToBase64url(credential.response.authenticatorData),
|
||||
signature: bufferToBase64url(credential.response.signature)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert JSON creation options into the format expected by the browser:
|
||||
// decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers.
|
||||
function prepareCreationOptions(options) {
|
||||
return {
|
||||
...options,
|
||||
challenge: base64urlToBuffer(options.challenge),
|
||||
user: { ...options.user, id: base64urlToBuffer(options.user.id) },
|
||||
excludeCredentials: (options.excludeCredentials || []).map(cred => ({
|
||||
...cred,
|
||||
id: base64urlToBuffer(cred.id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert JSON request options into the format expected by the browser:
|
||||
// decode base64url challenge and allowCredentials[].id into ArrayBuffers.
|
||||
// Strips allowCredentials entirely when empty so the browser prompts for
|
||||
// any available credential (required for conditional mediation).
|
||||
function prepareRequestOptions(options) {
|
||||
const prepared = {
|
||||
...options,
|
||||
challenge: base64urlToBuffer(options.challenge)
|
||||
}
|
||||
|
||||
if (options.allowCredentials?.length) {
|
||||
prepared.allowCredentials = options.allowCredentials.map(cred => ({
|
||||
...cred,
|
||||
id: base64urlToBuffer(cred.id)
|
||||
}))
|
||||
} else {
|
||||
delete prepared.allowCredentials
|
||||
}
|
||||
|
||||
return prepared
|
||||
}
|
||||
|
||||
function base64urlToBuffer(base64url) {
|
||||
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padding = "=".repeat((4 - base64.length % 4) % 4)
|
||||
const binary = atob(base64 + padding)
|
||||
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
|
||||
}
|
||||
|
||||
function bufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
const binary = String.fromCharCode(...bytes)
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
Reference in New Issue
Block a user