Add cross-reference map, admin health page, passkeys and device list, settings tabs
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph - Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs - Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts - Settings split into Account, Preferences, Notifications, Security, Your data tabs - ntfy server field: catch a topic typed into the server box Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
207bfcb919
commit
bdb04cdedc
35 files changed
+1860
-22
No files matched your search
@@ -5,9 +5,9 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
|
|||||||
## Running it and keeping it safe
|
## Running it and keeping it safe
|
||||||
- [ ] **Email (SMTP).** A mail setting for password resets and invite links, so they don't depend on the ntfy topic. Also lets a
|
- [ ] **Email (SMTP).** A mail setting for password resets and invite links, so they don't depend on the ntfy topic. Also lets a
|
||||||
co-teacher get their invite by email. Needs a `SMTP_URL`-style setting and a fallback to today's behaviour.
|
co-teacher get their invite by email. Needs a `SMTP_URL`-style setting and a fallback to today's behaviour.
|
||||||
- [ ] **Passkeys and a device list.** Sign in with Face ID or a fingerprint (WebAuthn); show where you're signed in with
|
- [x] **Passkeys and a device list.** Sign in with Face ID or a fingerprint (WebAuthn); show where you're signed in with
|
||||||
"sign out other devices"; optionally a login alert through ntfy.
|
"sign out other devices"; optionally a login alert through ntfy.
|
||||||
- [ ] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a
|
- [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a
|
||||||
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
|
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
|
||||||
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in,
|
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in,
|
||||||
creating a study and syncing; so far everything is tested in simulation.
|
creating a study and syncing; so far everything is tested in simulation.
|
||||||
@@ -23,7 +23,7 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
|
|||||||
## Reading
|
## Reading
|
||||||
- [ ] **Read-aloud with highlighting.** The device's voice reads the chapter and the verse being read is highlighted; useful for
|
- [ ] **Read-aloud with highlighting.** The device's voice reads the chapter and the verse being read is highlighted; useful for
|
||||||
the BSB and NET, which have no audio.
|
the BSB and NET, which have no audio.
|
||||||
- [ ] **Cross-reference map.** A picture of the links between chapters, from IQ Bible's reference graph (needs the IQ Bible key).
|
- [x] **Cross-reference map.** A picture of the links between chapters, from IQ Bible's reference graph (needs the IQ Bible key).
|
||||||
|
|
||||||
## Decided against, for now
|
## Decided against, for now
|
||||||
- Scripture memory (verse practice with spaced review).
|
- Scripture memory (verse practice with spaced review).
|
||||||
|
|||||||
Generated
+296
@@ -8,6 +8,8 @@
|
|||||||
"name": "bible-study-app",
|
"name": "bible-study-app",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.3",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"compression": "^1.8.2",
|
"compression": "^1.8.2",
|
||||||
@@ -1163,6 +1165,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hexagon/base64": {
|
||||||
|
"version": "1.1.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||||
|
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -1303,6 +1311,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@levischuck/tiny-cbor": {
|
||||||
|
"version": "0.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||||
|
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
@@ -1420,6 +1434,207 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@peculiar/asn1-android": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-IHKdYL5MRW76DOQCv2HSE/vUMOf2jqH4E7yhtQYe/xx+RxpPiXfkUdhex0N4/s5crsnXCGftRNzy4RJn1s6CZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.10.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-cms": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.10.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.10.0",
|
||||||
|
"@peculiar/asn1-x509-attr": "^2.10.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-csr": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.10.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.10.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-ecc": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.10.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.10.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-pfx": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-cms": "^2.10.0",
|
||||||
|
"@peculiar/asn1-pkcs8": "^2.10.0",
|
||||||
|
"@peculiar/asn1-rsa": "^2.10.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.10.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-pkcs8": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.10.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.10.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-pkcs9": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-cms": "^2.10.0",
|
||||||
|
"@peculiar/asn1-pfx": "^2.10.0",
|
||||||
|
"@peculiar/asn1-pkcs8": "^2.10.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.10.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.10.0",
|
||||||
|
"@peculiar/asn1-x509-attr": "^2.10.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-rsa": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.10.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.10.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-schema": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/utils": "^2.0.2",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-x509": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.10.0",
|
||||||
|
"@peculiar/utils": "^2.0.2",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-x509-attr": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.10.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.10.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/utils": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/x509": {
|
||||||
|
"version": "1.14.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
|
||||||
|
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-cms": "^2.6.0",
|
||||||
|
"@peculiar/asn1-csr": "^2.6.0",
|
||||||
|
"@peculiar/asn1-ecc": "^2.6.0",
|
||||||
|
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||||
|
"@peculiar/asn1-rsa": "^2.6.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.6.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.6.0",
|
||||||
|
"pvtsutils": "^1.3.6",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"tsyringe": "^4.10.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||||
@@ -2091,6 +2306,31 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@simplewebauthn/browser": {
|
||||||
|
"version": "13.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||||
|
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@simplewebauthn/server": {
|
||||||
|
"version": "13.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.3.tgz",
|
||||||
|
"integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@hexagon/base64": "^1.1.27",
|
||||||
|
"@levischuck/tiny-cbor": "^0.2.2",
|
||||||
|
"@peculiar/asn1-android": "^2.6.0",
|
||||||
|
"@peculiar/asn1-ecc": "^2.6.1",
|
||||||
|
"@peculiar/asn1-rsa": "^2.6.1",
|
||||||
|
"@peculiar/asn1-schema": "^2.6.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.6.1",
|
||||||
|
"@peculiar/x509": "^1.14.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2583,6 +2823,20 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1js": {
|
||||||
|
"version": "3.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||||
|
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"pvtsutils": "^1.3.6",
|
||||||
|
"pvutils": "^1.1.5",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -6200,6 +6454,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pvtsutils": {
|
||||||
|
"version": "1.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||||
|
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pvutils": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qified": {
|
"node_modules/qified": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
|
||||||
@@ -6493,6 +6765,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reflect-metadata": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -7383,6 +7661,24 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tsyringe": {
|
||||||
|
"version": "4.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||||
|
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^1.9.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsyringe/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.3",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"compression": "^1.8.2",
|
"compression": "^1.8.2",
|
||||||
|
|||||||
+21
-4
@@ -1,14 +1,15 @@
|
|||||||
// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups.
|
// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups.
|
||||||
// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with
|
// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with
|
||||||
// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off.
|
// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off.
|
||||||
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { backupDatabase, dataDir } from './db.js';
|
import { logEvent } from './health.js';
|
||||||
|
import { backupDatabase, dataDir, replaceDatabase } from './db.js';
|
||||||
|
|
||||||
const DAY = 24 * 60 * 60 * 1000;
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
export const backupDir = () => join(dataDir(), 'backups');
|
export const backupDir = () => join(dataDir(), 'backups');
|
||||||
const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
||||||
const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
|
export const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
|
||||||
|
|
||||||
export function listSnapshots(dir = backupDir()) {
|
export function listSnapshots(dir = backupDir()) {
|
||||||
if (!existsSync(dir)) return [];
|
if (!existsSync(dir)) return [];
|
||||||
@@ -32,13 +33,29 @@ export async function runBackup(now = new Date()) {
|
|||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const snapshotPath = (name) => (isSnapshot(name) && existsSync(join(backupDir(), name)) ? join(backupDir(), name) : null);
|
||||||
|
|
||||||
|
// Puts a snapshot back as the live database. A fresh snapshot of what is there now is taken first (from a
|
||||||
|
// copy of the chosen file, so the two can't collide), so a restore can itself be undone.
|
||||||
|
export async function restoreSnapshot(name, now = new Date()) {
|
||||||
|
const src = snapshotPath(name);
|
||||||
|
if (!src) throw new Error('No such snapshot.');
|
||||||
|
const hold = join(backupDir(), `.restoring-${Date.now()}.tmp`);
|
||||||
|
copyFileSync(src, hold);
|
||||||
|
try {
|
||||||
|
const before = await runBackup(now);
|
||||||
|
replaceDatabase(hold);
|
||||||
|
return { restored: name, safetyCopy: before };
|
||||||
|
} finally { unlinkSync(hold); }
|
||||||
|
}
|
||||||
|
|
||||||
// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up).
|
// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up).
|
||||||
export function startBackupScheduler() {
|
export function startBackupScheduler() {
|
||||||
if (process.env.BACKUP_DISABLED === '1') return;
|
if (process.env.BACKUP_DISABLED === '1') return;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const latest = listSnapshots()[0];
|
const latest = listSnapshots()[0];
|
||||||
if (latest && Date.now() - latest.at < DAY) return;
|
if (latest && Date.now() - latest.at < DAY) return;
|
||||||
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => console.error('Database snapshot failed:', err.message));
|
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => { console.error('Database snapshot failed:', err.message); logEvent('backup', err.message); });
|
||||||
};
|
};
|
||||||
setTimeout(tick, 30_000).unref();
|
setTimeout(tick, 30_000).unref();
|
||||||
setInterval(tick, 60 * 60 * 1000).unref();
|
setInterval(tick, 60 * 60 * 1000).unref();
|
||||||
|
|||||||
+60
-2
@@ -1,5 +1,5 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { mkdirSync } from 'fs';
|
import { copyFileSync, mkdirSync, rmSync } from 'fs';
|
||||||
import { MAX_VERSIONS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
|
import { MAX_VERSIONS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
@@ -17,6 +17,18 @@ export function backupDatabase(destPath) {
|
|||||||
return db.backup(destPath);
|
return db.backup(destPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Swaps the live database for a copy of `srcFile` (a snapshot). The connection is reopened either way. */
|
||||||
|
export function replaceDatabase(srcFile) {
|
||||||
|
db.close();
|
||||||
|
try {
|
||||||
|
copyFileSync(srcFile, DB_PATH);
|
||||||
|
rmSync(`${DB_PATH}-wal`, { force: true });
|
||||||
|
rmSync(`${DB_PATH}-shm`, { force: true });
|
||||||
|
} finally {
|
||||||
|
initDb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Init — create tables if they don't exist, migrate older schemas
|
// Init — create tables if they don't exist, migrate older schemas
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -48,6 +60,18 @@ export function initDb() {
|
|||||||
sess TEXT NOT NULL,
|
sess TEXT NOT NULL,
|
||||||
expires INTEGER NOT NULL
|
expires INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS passkeys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
counter INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transports TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Older databases predate multi-user support — add the ownership column.
|
// Older databases predate multi-user support — add the ownership column.
|
||||||
@@ -82,6 +106,9 @@ export function initDb() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional "new sign-in" notification, sent to the same ntfy topic.
|
||||||
|
if (!userCols.some((c) => c.name === 'login_alerts')) db.exec('ALTER TABLE users ADD COLUMN login_alerts INTEGER NOT NULL DEFAULT 0');
|
||||||
|
|
||||||
// Optional daily verse of the day, sent to the same ntfy topic.
|
// Optional daily verse of the day, sent to the same ntfy topic.
|
||||||
if (!userCols.some((c) => c.name === 'votd_enabled')) {
|
if (!userCols.some((c) => c.name === 'votd_enabled')) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
@@ -273,6 +300,7 @@ function parseUserRow(row) {
|
|||||||
reminderEnabled: !!row.reminderEnabled,
|
reminderEnabled: !!row.reminderEnabled,
|
||||||
votdEnabled: !!row.votdEnabled,
|
votdEnabled: !!row.votdEnabled,
|
||||||
prayerEnabled: !!row.prayerEnabled,
|
prayerEnabled: !!row.prayerEnabled,
|
||||||
|
loginAlerts: !!row.loginAlerts,
|
||||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -284,7 +312,7 @@ const USER_SELECT = `
|
|||||||
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||||
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz,
|
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz,
|
||||||
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate,
|
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate,
|
||||||
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime
|
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime, login_alerts AS loginAlerts
|
||||||
FROM users
|
FROM users
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -837,6 +865,7 @@ export function adminGetAllUsers() {
|
|||||||
|
|
||||||
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
|
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
|
||||||
export function adminDeleteUser(userId) {
|
export function adminDeleteUser(userId) {
|
||||||
|
db.prepare('DELETE FROM passkeys WHERE user_id = ?').run(userId);
|
||||||
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
|
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.prepare('DELETE FROM collaborators WHERE user_id = ?').run(userId);
|
db.prepare('DELETE FROM collaborators WHERE user_id = ?').run(userId);
|
||||||
@@ -931,3 +960,32 @@ export function destroyAllSessionsForUser(userId, exceptSid = null) {
|
|||||||
const placeholders = staleSids.map(() => '?').join(',');
|
const placeholders = staleSids.map(() => '?').join(',');
|
||||||
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
|
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Passkeys, sign-in alerts and the list of signed-in devices
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function setLoginAlerts(userId, enabled) {
|
||||||
|
db.prepare('UPDATE users SET login_alerts = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPasskey({ id, userId, publicKey, counter, transports, name }) {
|
||||||
|
db.prepare('INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(id, userId, publicKey, counter, JSON.stringify(transports ?? []), name, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
const passkeyRow = (r) => (r ? { ...r, transports: JSON.parse(r.transports || '[]') } : null);
|
||||||
|
const PASSKEY_COLS = 'id, user_id AS userId, public_key AS publicKey, counter, transports, name, created_at AS createdAt, last_used_at AS lastUsedAt';
|
||||||
|
|
||||||
|
export const getPasskey = (id) => passkeyRow(db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE id = ?`).get(id));
|
||||||
|
export const listPasskeys = (userId) => db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE user_id = ? ORDER BY created_at`).all(userId).map(passkeyRow);
|
||||||
|
export const touchPasskey = (id, counter) => db.prepare('UPDATE passkeys SET counter = ?, last_used_at = ? WHERE id = ?').run(counter, Date.now(), id);
|
||||||
|
export const deletePasskey = (id, userId) => db.prepare('DELETE FROM passkeys WHERE id = ? AND user_id = ?').run(id, userId).changes > 0;
|
||||||
|
export const renamePasskey = (id, userId, name) => db.prepare('UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?').run(name, id, userId).changes > 0;
|
||||||
|
|
||||||
|
/** Every live session of one user: [{ sid, sess, expires }]. */
|
||||||
|
export function listUserSessions(userId) {
|
||||||
|
return db.prepare('SELECT sid, sess, expires FROM sessions WHERE expires > ?').all(Date.now())
|
||||||
|
.map((r) => { try { return { sid: r.sid, sess: JSON.parse(r.sess), expires: r.expires }; } catch { return null; } })
|
||||||
|
.filter((r) => r?.sess?.userId === userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Passkeys (WebAuthn sign-in with Face ID, a fingerprint or a security key) and the list of devices signed in.
|
||||||
|
// A passkey is a second factor in itself (the device checks it is really you), so signing in with one skips the
|
||||||
|
// authenticator-code step. Passwords and 2FA keep working as before.
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import {
|
||||||
|
generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse,
|
||||||
|
} from '@simplewebauthn/server';
|
||||||
|
import {
|
||||||
|
addPasskey, deletePasskey, destroyAllSessionsForUser, destroySession, getPasskey, getUserById, listPasskeys,
|
||||||
|
listUserSessions, renamePasskey, setLoginAlerts, touchPasskey,
|
||||||
|
} from './db.js';
|
||||||
|
import { logEvent } from './health.js';
|
||||||
|
import { sendNtfy } from './reminders.js';
|
||||||
|
|
||||||
|
const MONTH = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const APP_NAME = 'Bible Study';
|
||||||
|
|
||||||
|
// "Chrome on macOS" from a User-Agent string; good enough to recognise your own devices.
|
||||||
|
export function describeDevice(ua = '') {
|
||||||
|
const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : null;
|
||||||
|
const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : /Mac OS X|Macintosh/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : null;
|
||||||
|
if (browser && os) return `${browser} on ${os}`;
|
||||||
|
return browser ?? os ?? 'Unknown device';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionKey = (sid) => createHash('sha256').update(sid).digest('hex').slice(0, 16);
|
||||||
|
|
||||||
|
// Where the browser says it is, for checking passkeys: APP_URL when set, else the address this request came to.
|
||||||
|
export function relyingParty(req, env = process.env) {
|
||||||
|
if (env.APP_URL) {
|
||||||
|
try { const u = new URL(env.APP_URL); return { rpID: u.hostname, origin: u.origin }; } catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return { rpID: req.hostname, origin: `${req.protocol}://${req.get('host')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called right after a session gets its userId: remembers the device and (if asked) sends a sign-in alert. */
|
||||||
|
export function noteSignIn(req, user, method) {
|
||||||
|
const label = describeDevice(req.get('user-agent') ?? '');
|
||||||
|
req.session.device = { label, ip: req.ip, at: Date.now(), method };
|
||||||
|
if (user.loginAlerts && user.ntfyTopic) {
|
||||||
|
const how = { password: 'password', passkey: 'passkey', mfa: 'password and authenticator code', register: 'a new account' }[method] ?? method;
|
||||||
|
sendNtfy(user, { title: 'New sign-in', body: `${label} signed in to your Bible Study account with ${how}. If that wasn’t you, change your password and sign out other devices.`, tags: 'warning' })
|
||||||
|
.catch((err) => { console.error('Sign-in alert failed:', err.message); logEvent('notify', `Sign-in alert: ${err.message}`, { userId: user.id }); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicPasskey = (p) => ({ id: p.id, name: p.name, createdAt: p.createdAt, lastUsedAt: p.lastUsedAt });
|
||||||
|
|
||||||
|
export function registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany }) {
|
||||||
|
const fail = (res, status, error) => res.status(status).json({ error });
|
||||||
|
|
||||||
|
// ---- Signing in with a passkey (no email needed: the passkey says who you are) ----
|
||||||
|
app.post('/api/auth/passkey/options', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(tooMany);
|
||||||
|
const { rpID } = relyingParty(req);
|
||||||
|
const options = await generateAuthenticationOptions({ rpID, userVerification: 'required' });
|
||||||
|
req.session.passkeyChallenge = options.challenge;
|
||||||
|
res.json(options);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/auth/passkey/options error:', err);
|
||||||
|
fail(res, 500, 'Could not start passkey sign-in.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/passkey/verify', async (req, res) => {
|
||||||
|
const keys = [`ip:${req.ip}`];
|
||||||
|
try {
|
||||||
|
if (isLimited(...keys)) return res.status(429).json(tooMany);
|
||||||
|
const challenge = req.session?.passkeyChallenge;
|
||||||
|
const response = req.body;
|
||||||
|
const passkey = typeof response?.id === 'string' ? getPasskey(response.id) : null;
|
||||||
|
const failed = () => { recordFailure(...keys); logEvent('signin', `Passkey sign-in failed from ${req.ip}`); return fail(res, 401, 'That passkey didn’t work. Try again, or use your password.'); };
|
||||||
|
if (!challenge || !passkey) return failed();
|
||||||
|
const { rpID, origin } = relyingParty(req);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await verifyAuthenticationResponse({
|
||||||
|
response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true,
|
||||||
|
credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter, transports: passkey.transports },
|
||||||
|
});
|
||||||
|
} catch { return failed(); }
|
||||||
|
if (!result.verified) return failed();
|
||||||
|
const user = getUserById(passkey.userId);
|
||||||
|
if (!user) return failed();
|
||||||
|
touchPasskey(passkey.id, result.authenticationInfo.newCounter);
|
||||||
|
clearFailures(...keys);
|
||||||
|
req.session.regenerate((err) => {
|
||||||
|
if (err) return fail(res, 500, 'Could not create session.');
|
||||||
|
req.session.userId = user.id;
|
||||||
|
noteSignIn(req, user, 'passkey');
|
||||||
|
res.json(userPayload(user));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/auth/passkey/verify error:', err);
|
||||||
|
fail(res, 500, 'Could not check that passkey.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Managing passkeys (signed in) ----
|
||||||
|
app.get('/api/passkeys', requireAuth, (req, res) => res.json(listPasskeys(req.session.userId).map(publicPasskey)));
|
||||||
|
|
||||||
|
app.post('/api/passkeys/options', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = getUserById(req.session.userId);
|
||||||
|
const { rpID } = relyingParty(req);
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: APP_NAME, rpID, userName: user.email, userID: new TextEncoder().encode(user.id), attestationType: 'none',
|
||||||
|
excludeCredentials: listPasskeys(user.id).map((p) => ({ id: p.id, transports: p.transports })),
|
||||||
|
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
|
||||||
|
});
|
||||||
|
req.session.passkeyChallenge = options.challenge;
|
||||||
|
res.json(options);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/passkeys/options error:', err);
|
||||||
|
fail(res, 500, 'Could not start adding a passkey.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/passkeys', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const challenge = req.session.passkeyChallenge;
|
||||||
|
if (!challenge) return fail(res, 400, 'Start again: that request expired.');
|
||||||
|
const { rpID, origin } = relyingParty(req);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await verifyRegistrationResponse({ response: req.body?.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true });
|
||||||
|
} catch (err) { return fail(res, 400, `That passkey couldn’t be added (${err.message}).`); }
|
||||||
|
if (!result.verified) return fail(res, 400, 'That passkey couldn’t be added.');
|
||||||
|
delete req.session.passkeyChallenge;
|
||||||
|
const { credential } = result.registrationInfo;
|
||||||
|
if (getPasskey(credential.id)) return fail(res, 409, 'That passkey is already registered.');
|
||||||
|
const name = String(req.body?.name ?? '').trim().slice(0, 60) || describeDevice(req.get('user-agent') ?? '');
|
||||||
|
addPasskey({ id: credential.id, userId: req.session.userId, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: credential.counter, transports: credential.transports, name });
|
||||||
|
res.json(listPasskeys(req.session.userId).map(publicPasskey));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/passkeys error:', err);
|
||||||
|
fail(res, 500, 'Could not save that passkey.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/passkeys/:id', requireAuth, (req, res) => {
|
||||||
|
const name = String(req.body?.name ?? '').trim().slice(0, 60);
|
||||||
|
if (!name) return fail(res, 400, 'Give it a name.');
|
||||||
|
if (!renamePasskey(req.params.id, req.session.userId, name)) return fail(res, 404, 'No such passkey.');
|
||||||
|
res.json(listPasskeys(req.session.userId).map(publicPasskey));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/passkeys/:id', requireAuth, (req, res) => {
|
||||||
|
if (!deletePasskey(req.params.id, req.session.userId)) return fail(res, 404, 'No such passkey.');
|
||||||
|
res.json(listPasskeys(req.session.userId).map(publicPasskey));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Where you are signed in ----
|
||||||
|
app.get('/api/sessions', requireAuth, (req, res) => {
|
||||||
|
const rows = listUserSessions(req.session.userId).map(({ sid, sess, expires }) => ({
|
||||||
|
id: sessionKey(sid), current: sid === req.sessionID,
|
||||||
|
label: sess.device?.label ?? 'Signed in before this list existed', ip: sess.device?.ip ?? null,
|
||||||
|
method: sess.device?.method ?? null, signedInAt: sess.device?.at ?? null,
|
||||||
|
lastSeen: expires - MONTH, // sessions are renewed on each request, so this is when it was last used
|
||||||
|
})).sort((a, b) => Number(b.current) - Number(a.current) || b.lastSeen - a.lastSeen);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
|
||||||
|
const found = listUserSessions(req.session.userId).find((s) => sessionKey(s.sid) === req.params.id);
|
||||||
|
if (!found) return fail(res, 404, 'That device is already signed out.');
|
||||||
|
if (found.sid === req.sessionID) return fail(res, 400, 'Use Sign out for this device.');
|
||||||
|
destroySession(found.sid);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/sessions/sign-out-others', requireAuth, (req, res) => {
|
||||||
|
destroyAllSessionsForUser(req.session.userId, req.sessionID);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/auth/login-alerts', requireAuth, (req, res) => {
|
||||||
|
const user = getUserById(req.session.userId);
|
||||||
|
const enabled = !!req.body?.enabled;
|
||||||
|
if (enabled && !user.ntfyTopic) return fail(res, 400, 'Set up a notification topic first (under Notifications).');
|
||||||
|
setLoginAlerts(user.id, enabled);
|
||||||
|
res.json({ loginAlerts: enabled });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
import { createHash, generateKeyPairSync, randomBytes, sign } from 'crypto';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import express from 'express';
|
||||||
|
import session from 'express-session';
|
||||||
|
|
||||||
|
const b64 = (b) => Buffer.from(b).toString('base64url');
|
||||||
|
const sha = (b) => createHash('sha256').update(b).digest();
|
||||||
|
|
||||||
|
// A tiny CBOR writer, just enough for the attestation a passkey device would send.
|
||||||
|
const head = (major, n) => (n < 24 ? Buffer.from([(major << 5) | n]) : n < 256 ? Buffer.from([(major << 5) | 24, n]) : Buffer.from([(major << 5) | 25, n >> 8, n & 255]));
|
||||||
|
const cbor = (v) => {
|
||||||
|
if (Buffer.isBuffer(v)) return Buffer.concat([head(2, v.length), v]);
|
||||||
|
if (typeof v === 'string') return Buffer.concat([head(3, Buffer.byteLength(v)), Buffer.from(v)]);
|
||||||
|
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
|
||||||
|
const entries = v instanceof Map ? [...v] : Object.entries(v);
|
||||||
|
return Buffer.concat([head(5, entries.length), ...entries.flatMap(([k, x]) => [cbor(k), cbor(x)])]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A software "phone": makes passkeys and answers sign-in challenges, like Face ID would.
|
||||||
|
function authenticator(rpID, origin) {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||||
|
const jwk = publicKey.export({ format: 'jwk' });
|
||||||
|
const credId = randomBytes(16);
|
||||||
|
let counter = 0;
|
||||||
|
const rpHash = sha(rpID);
|
||||||
|
const client = (type, challenge, o = origin) => Buffer.from(JSON.stringify({ type, challenge, origin: o, crossOrigin: false }));
|
||||||
|
return {
|
||||||
|
id: b64(credId),
|
||||||
|
create(challenge, o) {
|
||||||
|
const cose = cbor(new Map([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x, 'base64url')], [-3, Buffer.from(jwk.y, 'base64url')]]));
|
||||||
|
const len = Buffer.alloc(2); len.writeUInt16BE(credId.length);
|
||||||
|
const cnt = Buffer.alloc(4);
|
||||||
|
const authData = Buffer.concat([rpHash, Buffer.from([0x45]), cnt, Buffer.alloc(16), len, credId, cose]);
|
||||||
|
return { id: b64(credId), rawId: b64(credId), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(client('webauthn.create', challenge, o)), attestationObject: b64(cbor({ fmt: 'none', attStmt: {}, authData })), transports: ['internal'] } };
|
||||||
|
},
|
||||||
|
get(challenge, o) {
|
||||||
|
counter += 1;
|
||||||
|
const cnt = Buffer.alloc(4); cnt.writeUInt32BE(counter);
|
||||||
|
const authData = Buffer.concat([rpHash, Buffer.from([0x05]), cnt]);
|
||||||
|
const clientData = client('webauthn.get', challenge, o);
|
||||||
|
const signature = sign('sha256', Buffer.concat([authData, sha(clientData)]), privateKey);
|
||||||
|
return { id: b64(credId), rawId: b64(credId), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(clientData), authenticatorData: b64(authData), signature: b64(signature), userHandle: null } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir; let server; let base; let db;
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'devices-'));
|
||||||
|
process.env.DATA_DIR = dir; delete process.env.APP_URL;
|
||||||
|
vi.resetModules();
|
||||||
|
db = await import('./db.js'); db.initDb();
|
||||||
|
const { registerDeviceRoutes } = await import('./devices.js');
|
||||||
|
const { requireAuth } = await import('./auth.js');
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(session({ secret: 'test', resave: false, saveUninitialized: false, store: new (await import('./sessionStore.js')).SqliteSessionStore(), cookie: { maxAge: 30 * 24 * 3600 * 1000 } }));
|
||||||
|
const { noteSignIn } = await import('./devices.js');
|
||||||
|
// stand-in for the password sign-in route
|
||||||
|
app.post('/test/login', (req, res) => { const u = db.getUserByEmail(req.body.email); req.session.regenerate(() => { req.session.userId = u.id; noteSignIn(req, u, 'password'); res.json({ ok: true }); }); });
|
||||||
|
registerDeviceRoutes(app, { requireAuth, userPayload: (u) => ({ id: u.id, email: u.email }), isLimited: () => false, recordFailure() {}, clearFailures() {}, tooMany: { error: 'slow down' } });
|
||||||
|
await new Promise((r) => { server = app.listen(0, r); });
|
||||||
|
base = `http://localhost:${server.address().port}`;
|
||||||
|
db.createUser({ id: 'u1', email: 'a@x.test', passwordHash: 'h' });
|
||||||
|
});
|
||||||
|
afterEach(async () => { await new Promise((r) => server.close(r)); rmSync(dir, { recursive: true, force: true }); delete process.env.DATA_DIR; });
|
||||||
|
|
||||||
|
// A browser: keeps its own cookie
|
||||||
|
function browser(ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17 Safari/605.1.15') {
|
||||||
|
let cookie = '';
|
||||||
|
const call = async (method, path, body) => {
|
||||||
|
const res = await fetch(base + path, { method, headers: { 'Content-Type': 'application/json', 'User-Agent': ua, ...(cookie ? { Cookie: cookie } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||||
|
const set = res.headers.get('set-cookie');
|
||||||
|
if (set) cookie = set.split(';')[0];
|
||||||
|
return { status: res.status, body: await res.json().catch(() => null) };
|
||||||
|
};
|
||||||
|
return { call, login: () => call('POST', '/test/login', { email: 'a@x.test' }) };
|
||||||
|
}
|
||||||
|
const originOf = () => base;
|
||||||
|
|
||||||
|
test('add a passkey, then sign in with it from another device', async () => {
|
||||||
|
const phone = authenticator('localhost', originOf());
|
||||||
|
const laptop = browser(); await laptop.login();
|
||||||
|
const { body: opts } = await laptop.call('POST', '/api/passkeys/options');
|
||||||
|
expect(opts.authenticatorSelection.userVerification).toBe('required');
|
||||||
|
const added = await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge), name: 'My phone' });
|
||||||
|
expect(added.status).toBe(200);
|
||||||
|
expect(added.body).toMatchObject([{ id: phone.id, name: 'My phone' }]);
|
||||||
|
|
||||||
|
const other = browser('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1');
|
||||||
|
expect((await other.call('GET', '/api/passkeys')).status).toBe(401); // not signed in yet
|
||||||
|
const { body: ask } = await other.call('POST', '/api/auth/passkey/options');
|
||||||
|
const ok = await other.call('POST', '/api/auth/passkey/verify', phone.get(ask.challenge));
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect(ok.body.email).toBe('a@x.test');
|
||||||
|
expect((await other.call('GET', '/api/passkeys')).body[0].lastUsedAt).toBeGreaterThan(0);
|
||||||
|
const devices = (await other.call('GET', '/api/sessions')).body;
|
||||||
|
expect(devices.map((d) => d.label)).toEqual(['Safari on iPhone', 'Safari on macOS']);
|
||||||
|
expect(devices[0]).toMatchObject({ current: true, method: 'passkey' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a passkey from the wrong site, an unknown one, or a replay is refused', async () => {
|
||||||
|
const phone = authenticator('localhost', originOf());
|
||||||
|
const laptop = browser(); await laptop.login();
|
||||||
|
const { body: opts } = await laptop.call('POST', '/api/passkeys/options');
|
||||||
|
const bad = await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge, 'https://evil.example') });
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge), name: 'ok' });
|
||||||
|
|
||||||
|
const stranger = browser();
|
||||||
|
const { body: ask } = await stranger.call('POST', '/api/auth/passkey/options');
|
||||||
|
expect((await stranger.call('POST', '/api/auth/passkey/verify', authenticator('localhost', originOf()).get(ask.challenge))).status).toBe(401); // never registered
|
||||||
|
expect((await stranger.call('POST', '/api/auth/passkey/verify', phone.get('wrong-challenge'))).status).toBe(401);
|
||||||
|
const good = phone.get(ask.challenge);
|
||||||
|
expect((await stranger.call('POST', '/api/auth/passkey/verify', good)).status).toBe(200);
|
||||||
|
const replay = browser();
|
||||||
|
await replay.call('POST', '/api/auth/passkey/options');
|
||||||
|
expect((await replay.call('POST', '/api/auth/passkey/verify', good)).status).toBe(401); // that answer was for another challenge
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passkeys can be renamed and removed, and only by their owner', async () => {
|
||||||
|
const phone = authenticator('localhost', originOf());
|
||||||
|
const me = browser(); await me.login();
|
||||||
|
const { body: opts } = await me.call('POST', '/api/passkeys/options');
|
||||||
|
await me.call('POST', '/api/passkeys', { response: phone.create(opts.challenge) });
|
||||||
|
expect((await me.call('PATCH', `/api/passkeys/${phone.id}`, { name: 'Pixel' })).body[0].name).toBe('Pixel');
|
||||||
|
expect((await me.call('PATCH', `/api/passkeys/${phone.id}`, { name: ' ' })).status).toBe(400);
|
||||||
|
db.createUser({ id: 'u2', email: 'b@x.test', passwordHash: 'h' });
|
||||||
|
expect(db.deletePasskey(phone.id, 'u2')).toBe(false);
|
||||||
|
expect((await me.call('DELETE', `/api/passkeys/${phone.id}`)).body).toEqual([]);
|
||||||
|
const { body: ask } = await browser().call('POST', '/api/auth/passkey/options');
|
||||||
|
expect(ask.challenge).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('devices can be signed out one by one or all at once', async () => {
|
||||||
|
const a = browser(); const b = browser('Mozilla/5.0 (Windows NT 10.0) Chrome/120 Safari/537'); const c = browser('Mozilla/5.0 (Linux; Android 14) Chrome/120 Mobile Safari/537');
|
||||||
|
await a.login(); await b.login(); await c.login();
|
||||||
|
const list = (await a.call('GET', '/api/sessions')).body;
|
||||||
|
expect(list).toHaveLength(3);
|
||||||
|
expect(list[0].current).toBe(true);
|
||||||
|
expect((await a.call('DELETE', `/api/sessions/${list[0].id}`)).status).toBe(400); // not your own
|
||||||
|
const bId = list.find((d) => d.label === 'Chrome on Windows').id;
|
||||||
|
expect((await a.call('DELETE', `/api/sessions/${bId}`)).status).toBe(200);
|
||||||
|
expect((await b.call('GET', '/api/sessions')).status).toBe(401);
|
||||||
|
expect((await a.call('DELETE', `/api/sessions/${bId}`)).status).toBe(404);
|
||||||
|
await a.call('POST', '/api/sessions/sign-out-others');
|
||||||
|
expect((await c.call('GET', '/api/sessions')).status).toBe(401);
|
||||||
|
expect((await a.call('GET', '/api/sessions')).body).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sign-in alerts need a notification topic', async () => {
|
||||||
|
const a = browser(); await a.login();
|
||||||
|
expect((await a.call('PUT', '/api/auth/login-alerts', { enabled: true })).status).toBe(400);
|
||||||
|
db.setReminderSettings('u1', { ntfyUrl: null, ntfyTopic: 'my-topic', reminderTime: '08:00', reminderEnabled: false, reminderTz: 'UTC' });
|
||||||
|
expect((await a.call('PUT', '/api/auth/login-alerts', { enabled: true })).body).toEqual({ loginAlerts: true });
|
||||||
|
expect(db.getUserById('u1').loginAlerts).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('devices are described in plain words', async () => {
|
||||||
|
const { describeDevice, relyingParty } = await import('./devices.js');
|
||||||
|
expect(describeDevice('Mozilla/5.0 (Windows NT 10.0) Chrome/120 Edg/120')).toBe('Edge on Windows');
|
||||||
|
expect(describeDevice('Mozilla/5.0 (X11; Linux) Firefox/120')).toBe('Firefox on Linux');
|
||||||
|
expect(describeDevice('')).toBe('Unknown device');
|
||||||
|
const req = { hostname: 'study.example', protocol: 'https', get: () => 'study.example:8443' };
|
||||||
|
expect(relyingParty(req, {})).toEqual({ rpID: 'study.example', origin: 'https://study.example:8443' });
|
||||||
|
expect(relyingParty(req, { APP_URL: 'https://study.example/app' })).toEqual({ rpID: 'study.example', origin: 'https://study.example' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// What the admin's Health page shows: a short in-memory log of things that went wrong (failed sign-ins, failed
|
||||||
|
// notifications, backups), plus disk use and the snapshot list. The log resets when the server restarts.
|
||||||
|
import { existsSync, statSync, statfsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { dataDir } from './db.js';
|
||||||
|
import { backupDir, listSnapshots } from './backups.js';
|
||||||
|
|
||||||
|
const MAX_EVENTS = 200;
|
||||||
|
const events = [];
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
// kind: 'signin' | 'notify' | 'backup'. `ok` false marks a failure.
|
||||||
|
export function logEvent(kind, detail, { ok = false, userId = null } = {}) {
|
||||||
|
events.unshift({ at: Date.now(), kind, ok, detail: String(detail).slice(0, 300), userId });
|
||||||
|
if (events.length > MAX_EVENTS) events.length = MAX_EVENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recentEvents = (kind, limit = 50) => events.filter((e) => !kind || e.kind === kind).slice(0, limit);
|
||||||
|
export function clearEvents() { events.length = 0; }
|
||||||
|
|
||||||
|
const size = (path) => { try { return statSync(path).size; } catch { return 0; } };
|
||||||
|
|
||||||
|
export function diskUse() {
|
||||||
|
const dir = dataDir();
|
||||||
|
const db = size(join(dir, 'projects.db')) + size(join(dir, 'projects.db-wal'));
|
||||||
|
const backups = listSnapshots(backupDir()).reduce((n, s) => n + s.bytes, 0);
|
||||||
|
let free = null; let total = null;
|
||||||
|
try { const s = statfsSync(existsSync(dir) ? dir : '.'); free = s.bavail * s.bsize; total = s.blocks * s.bsize; } catch { /* not supported here */ }
|
||||||
|
return { db, backups, free, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backupStatus() {
|
||||||
|
const snapshots = listSnapshots(backupDir());
|
||||||
|
return { enabled: process.env.BACKUP_DISABLED !== '1', keep: Number(process.env.BACKUP_KEEP) || 7, snapshots, latest: snapshots[0] ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uptimeSeconds = () => Math.round((Date.now() - started) / 1000);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
let dir;
|
||||||
|
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'health-')); process.env.DATA_DIR = dir; vi.resetModules(); });
|
||||||
|
afterEach(() => { rmSync(dir, { recursive: true, force: true }); delete process.env.DATA_DIR; });
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const db = await import('./db.js'); db.initDb();
|
||||||
|
return { db, backups: await import('./backups.js'), health: await import('./health.js') };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('the event log keeps the newest first, filters by kind and is capped', async () => {
|
||||||
|
const { health } = await load();
|
||||||
|
health.logEvent('signin', 'a'); health.logEvent('notify', 'b'); health.logEvent('signin', 'c');
|
||||||
|
expect(health.recentEvents('signin').map((e) => e.detail)).toEqual(['c', 'a']);
|
||||||
|
for (let i = 0; i < 300; i += 1) health.logEvent('signin', String(i));
|
||||||
|
expect(health.recentEvents(null, 1000)).toHaveLength(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a snapshot can be restored, and what was there is kept as a safety copy', async () => {
|
||||||
|
const { db, backups } = await load();
|
||||||
|
db.createUser({ id: 'u1', email: 'a@x.test', passwordHash: 'h' });
|
||||||
|
await backups.runBackup(new Date('2026-10-01T10:00:00Z'));
|
||||||
|
db.createUser({ id: 'u2', email: 'b@x.test', passwordHash: 'h' });
|
||||||
|
expect(db.countUsers()).toBe(2);
|
||||||
|
const [snap] = backups.listSnapshots();
|
||||||
|
const result = await backups.restoreSnapshot(snap.name, new Date('2026-10-02T10:00:00Z'));
|
||||||
|
expect(db.countUsers()).toBe(1); // back to when the snapshot was taken
|
||||||
|
expect(backups.listSnapshots().map((s) => s.name)).toContain(result.safetyCopy.split('/').pop());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only real snapshot names resolve', async () => {
|
||||||
|
const { backups } = await load();
|
||||||
|
expect(backups.snapshotPath('../projects.db')).toBeNull();
|
||||||
|
expect(backups.snapshotPath('projects-2026-01-01-00-00.db')).toBeNull(); // valid name, but no such file
|
||||||
|
await expect(backups.restoreSnapshot('nope')).rejects.toThrow('No such snapshot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disk use reports the database and snapshot sizes', async () => {
|
||||||
|
const { backups, health } = await load();
|
||||||
|
await backups.runBackup();
|
||||||
|
const use = health.diskUse();
|
||||||
|
expect(use.db).toBeGreaterThan(0);
|
||||||
|
expect(use.backups).toBeGreaterThan(0);
|
||||||
|
expect(health.backupStatus().latest).not.toBeNull();
|
||||||
|
});
|
||||||
+60
-2
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import {
|
import {
|
||||||
initDb, dataDir, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
|
initDb, dataDir, listPasskeys, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
|
||||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||||
setShareToken, clearShareToken, getShareSettings, setShareSettings, getSharedByToken, getSharePassHash,
|
setShareToken, clearShareToken, getShareSettings, setShareSettings, getSharedByToken, getSharePassHash,
|
||||||
@@ -35,11 +35,13 @@ import { votdAvailable } from './votd.js';
|
|||||||
import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js';
|
import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js';
|
||||||
import { registerBibleRoutes } from './bibleApi.js';
|
import { registerBibleRoutes } from './bibleApi.js';
|
||||||
import { registerIqBibleRoutes } from './iqbible.js';
|
import { registerIqBibleRoutes } from './iqbible.js';
|
||||||
|
import { registerDeviceRoutes, noteSignIn } from './devices.js';
|
||||||
import { expiryFromDays, hashPasscode, checkShareAccess } from './shareLinks.js';
|
import { expiryFromDays, hashPasscode, checkShareAccess } from './shareLinks.js';
|
||||||
import { SIGNUP_MODES, isSignupMode, checkSignup, generateInviteCode, normalizeInviteCode } from './signup.js';
|
import { SIGNUP_MODES, isSignupMode, checkSignup, generateInviteCode, normalizeInviteCode } from './signup.js';
|
||||||
import { mergeHistory } from './readerHistory.js';
|
import { mergeHistory } from './readerHistory.js';
|
||||||
import { buildAccountExport, accountExportName } from './accountExport.js';
|
import { buildAccountExport, accountExportName } from './accountExport.js';
|
||||||
import { startBackupScheduler } from './backups.js';
|
import { startBackupScheduler, runBackup, restoreSnapshot, snapshotPath } from './backups.js';
|
||||||
|
import { logEvent, recentEvents, diskUse, backupStatus, uptimeSeconds } from './health.js';
|
||||||
import { cleanMarks } from './readerMarks.js';
|
import { cleanMarks } from './readerMarks.js';
|
||||||
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
|
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||||
import { isSaveConflict, parseBase } from './projectSync.js';
|
import { isSaveConflict, parseBase } from './projectSync.js';
|
||||||
@@ -54,6 +56,7 @@ function userPayload(user) {
|
|||||||
reminderTime: user.reminderTime ?? null,
|
reminderTime: user.reminderTime ?? null,
|
||||||
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
|
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
|
||||||
prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null,
|
prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null,
|
||||||
|
loginAlerts: !!user.loginAlerts, passkeyCount: listPasskeys(user.id).length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +188,7 @@ app.post('/api/auth/register', async (req, res) => {
|
|||||||
req.session.regenerate((err) => {
|
req.session.regenerate((err) => {
|
||||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
|
noteSignIn(req, user, 'register');
|
||||||
res.json({
|
res.json({
|
||||||
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
|
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
|
||||||
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
|
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
|
||||||
@@ -207,6 +211,7 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
|
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
recordFailure(...keys);
|
recordFailure(...keys);
|
||||||
|
logEvent('signin', `Wrong password for ${email.slice(0, 80) || '(blank)'} from ${req.ip}`);
|
||||||
return res.status(401).json({ error: 'Incorrect email or password.' });
|
return res.status(401).json({ error: 'Incorrect email or password.' });
|
||||||
}
|
}
|
||||||
clearFailures(`acct:${email}`);
|
clearFailures(`acct:${email}`);
|
||||||
@@ -220,6 +225,7 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
return res.json({ mfaRequired: true });
|
return res.json({ mfaRequired: true });
|
||||||
}
|
}
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
|
noteSignIn(req, user, 'password');
|
||||||
res.json(userPayload(user));
|
res.json(userPayload(user));
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -256,6 +262,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
|
|||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
recordFailure(...keys);
|
recordFailure(...keys);
|
||||||
|
logEvent('signin', `Wrong two-factor code for ${user.email} from ${req.ip}`, { userId: user.id });
|
||||||
return res.status(401).json({ error: 'Invalid code.' });
|
return res.status(401).json({ error: 'Invalid code.' });
|
||||||
}
|
}
|
||||||
clearFailures(`mfa:${pendingUserId}`);
|
clearFailures(`mfa:${pendingUserId}`);
|
||||||
@@ -263,6 +270,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
|
|||||||
req.session.regenerate((err) => {
|
req.session.regenerate((err) => {
|
||||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
|
noteSignIn(req, user, 'mfa');
|
||||||
res.json(userPayload(user));
|
res.json(userPayload(user));
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -754,6 +762,7 @@ app.put('/api/reader/marks', requireAuth, (req, res) => {
|
|||||||
// GET /api/reading-plans — the current user's whole progress list
|
// GET /api/reading-plans — the current user's whole progress list
|
||||||
registerBibleRoutes(app, requireAuth);
|
registerBibleRoutes(app, requireAuth);
|
||||||
registerIqBibleRoutes(app, requireAuth);
|
registerIqBibleRoutes(app, requireAuth);
|
||||||
|
registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY });
|
||||||
|
|
||||||
app.get('/api/reading-plans', requireAuth, (req, res) => {
|
app.get('/api/reading-plans', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -996,6 +1005,55 @@ app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Who may sign up, and the invite codes for it
|
// Who may sign up, and the invite codes for it
|
||||||
|
// Server health: disk use, snapshots, and a short log of failures (in memory; resets on restart).
|
||||||
|
app.get('/api/admin/health', requireAuth, requireAdmin, (_req, res) => {
|
||||||
|
try {
|
||||||
|
const emailOf = (id) => (id ? getUserById(id)?.email ?? null : null);
|
||||||
|
const withUser = (e) => ({ ...e, email: emailOf(e.userId) });
|
||||||
|
res.json({
|
||||||
|
uptimeSeconds: uptimeSeconds(), node: process.version, dataDir: dataDir(),
|
||||||
|
disk: diskUse(), backups: backupStatus(),
|
||||||
|
iqConfigured: !!process.env.IQBIBLE_API_KEY,
|
||||||
|
signinFailures: recentEvents('signin', 30).map(withUser),
|
||||||
|
notifyFailures: recentEvents('notify', 30).map(withUser),
|
||||||
|
backupFailures: recentEvents('backup', 10),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/admin/health error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to read server health.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/backups', requireAuth, requireAdmin, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await runBackup();
|
||||||
|
res.json(backupStatus());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/admin/backups error:', err);
|
||||||
|
logEvent('backup', err.message);
|
||||||
|
res.status(500).json({ error: 'The snapshot could not be made.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/backups/:name', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
const file = snapshotPath(req.params.name);
|
||||||
|
if (!file) return res.status(404).json({ error: 'No such snapshot.' });
|
||||||
|
res.download(file, req.params.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restoring replaces every account's data, so the snapshot's name has to be typed back as confirmation.
|
||||||
|
app.post('/api/admin/backups/:name/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||||
|
if (!snapshotPath(req.params.name)) return res.status(404).json({ error: 'No such snapshot.' });
|
||||||
|
if (req.body?.confirm !== req.params.name) return res.status(400).json({ error: 'Type the snapshot’s name to confirm.' });
|
||||||
|
try {
|
||||||
|
const result = await restoreSnapshot(req.params.name);
|
||||||
|
res.json({ restored: result.restored, safetyCopy: result.safetyCopy.split('/').pop() });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/admin/backups/:name/restore error:', err);
|
||||||
|
res.status(500).json({ error: 'The snapshot could not be restored.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
|
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
|
||||||
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
|
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ export const ROUTES = [
|
|||||||
route('/genealogies/@'), route('/bible-characters/@'), route('/bible-people/@'), route('/bible-people/@/@'),
|
route('/genealogies/@'), route('/bible-characters/@'), route('/bible-people/@'), route('/bible-people/@/@'),
|
||||||
route('/harmony'), route('/harmony/@'), route('/textual-variants/@/@'), route('/textual-variants/@/@/@'),
|
route('/harmony'), route('/harmony/@'), route('/textual-variants/@/@'), route('/textual-variants/@/@/@'),
|
||||||
route('/chronology/for/@/@'), route('/reference/weights-measures'),
|
route('/chronology/for/@/@'), route('/reference/weights-measures'),
|
||||||
route('/cross-references/@/@'), route('/cross-references/@/@/@'),
|
route('/cross-references/graph'), route('/cross-references/@/@'), route('/cross-references/@/@/@'),
|
||||||
route('/dictionaries'), route('/dictionaries/@'),
|
route('/dictionaries'), route('/dictionaries/@'),
|
||||||
route('/devotionals/today'), route('/devotionals/@/@'),
|
route('/devotionals/today'), route('/devotionals/@/@'),
|
||||||
route('/bibles'), route('/bibles/languages'), route('/bibles/parallel'), route('/bibles/@'), route('/bibles/@/@'), route('/bibles/@/verse-of-the-day'), route('/bibles/@/reading-plan'),
|
route('/bibles'), route('/bibles/languages'), route('/bibles/parallel'), route('/bibles/@'), route('/bibles/@/@'), route('/bibles/@/verse-of-the-day'), route('/bibles/@/reading-plan'),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { lookup } from 'dns/promises';
|
|||||||
import { isIP } from 'net';
|
import { isIP } from 'net';
|
||||||
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate, getUsersWithPrayerReminder, setLastPrayerDate, getPrayers } from './db.js';
|
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate, getUsersWithPrayerReminder, setLastPrayerDate, getPrayers } from './db.js';
|
||||||
import { openPrayers } from './prayers.js';
|
import { openPrayers } from './prayers.js';
|
||||||
|
import { logEvent } from './health.js';
|
||||||
import { fetchVerseOfDay, votdMessage } from './votd.js';
|
import { fetchVerseOfDay, votdMessage } from './votd.js';
|
||||||
|
|
||||||
// A reminder still goes out if the server was briefly down (or restarting) at the
|
// A reminder still goes out if the server was briefly down (or restarting) at the
|
||||||
@@ -119,6 +120,7 @@ export async function checkReminders(now = new Date()) {
|
|||||||
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
|
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
|
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
|
||||||
|
logEvent('notify', `Reading reminder: ${err.message}`, { userId: user.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +141,7 @@ export async function checkVotd(now = new Date()) {
|
|||||||
setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date);
|
setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Verse of the day failed for user ${user.id}:`, err.message);
|
console.error(`Verse of the day failed for user ${user.id}:`, err.message);
|
||||||
|
logEvent('notify', `Verse of the day: ${err.message}`, { userId: user.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +172,7 @@ export async function checkPrayerReminders(now = new Date()) {
|
|||||||
setLastPrayerDate(user.id, zonedNow(now, user.reminderTz).date);
|
setLastPrayerDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Prayer reminder failed for user ${user.id}:`, err.message);
|
console.error(`Prayer reminder failed for user ${user.id}:`, err.message);
|
||||||
|
logEvent('notify', `Prayer reminder: ${err.message}`, { userId: user.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Admin → Health: is the server in good shape? Disk use, snapshots (make, download, restore) and recent failures.
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { adminBackupNow, adminGetHealth, adminRestoreSnapshot, adminSnapshotUrl } from '../syncService.js';
|
||||||
|
|
||||||
|
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||||
|
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4';
|
||||||
|
|
||||||
|
export const formatBytes = (n) => {
|
||||||
|
if (n == null) return 'unknown';
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||||
|
let v = n / 1024; let i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
|
||||||
|
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||||
|
};
|
||||||
|
export const formatUptime = (s) => {
|
||||||
|
if (s < 90) return `${s} seconds`;
|
||||||
|
if (s < 5400) return `${Math.round(s / 60)} minutes`;
|
||||||
|
if (s < 129600) return `${Math.round(s / 3600)} hours`;
|
||||||
|
return `${Math.round(s / 86400)} days`;
|
||||||
|
};
|
||||||
|
const ago = (at) => {
|
||||||
|
const m = Math.round((Date.now() - at) / 60000);
|
||||||
|
if (m < 2) return 'just now';
|
||||||
|
if (m < 120) return `${m} minutes ago`;
|
||||||
|
if (m < 2880) return `${Math.round(m / 60)} hours ago`;
|
||||||
|
return `${Math.round(m / 1440)} days ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Log({ title, empty, rows }) {
|
||||||
|
return (
|
||||||
|
<section className={card}>
|
||||||
|
<h3 className="text-sm font-medium">{title}</h3>
|
||||||
|
{rows.length === 0 ? <p className="mt-2 text-xs text-[var(--r-sub)]">{empty}</p> : (
|
||||||
|
<ul className="mt-2 divide-y divide-[var(--r-line)] text-xs">
|
||||||
|
{rows.map((e, i) => (
|
||||||
|
<li key={`${e.at}-${i}`} className="flex flex-wrap justify-between gap-x-3 py-1.5">
|
||||||
|
<span className="min-w-0 break-words">{e.detail}{e.email && e.kind === 'notify' ? ` (${e.email})` : ''}</span>
|
||||||
|
<span className="shrink-0 text-[var(--r-sub)]">{ago(e.at)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminHealth() {
|
||||||
|
const [state, setState] = useState({ loading: true, error: '', data: null });
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [restoring, setRestoring] = useState(null); // snapshot name awaiting typed confirmation
|
||||||
|
const [typed, setTyped] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const r = await adminGetHealth();
|
||||||
|
setState(r.ok ? { loading: false, error: '', data: r.data } : { loading: false, error: r.error ?? 'Could not read server health.', data: null });
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const backupNow = async () => {
|
||||||
|
setBusy(true); setMessage('');
|
||||||
|
const r = await adminBackupNow();
|
||||||
|
setBusy(false);
|
||||||
|
setMessage(r.ok ? 'Snapshot saved.' : r.error ?? 'The snapshot could not be made.');
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const restore = async () => {
|
||||||
|
setBusy(true); setMessage('');
|
||||||
|
const r = await adminRestoreSnapshot(restoring);
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) { setRestoring(null); setTyped(''); setMessage(`Restored ${r.data.restored}. What was there before is saved as ${r.data.safetyCopy}. You may need to sign in again.`); load(); }
|
||||||
|
else setMessage(r.error ?? 'The snapshot could not be restored.');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.loading) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||||
|
if (state.error) return <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>;
|
||||||
|
const { data } = state;
|
||||||
|
const { backups, disk } = data;
|
||||||
|
const stale = backups.enabled && (!backups.latest || Date.now() - backups.latest.at > 36 * 3600 * 1000);
|
||||||
|
const usedPct = disk.total ? Math.round(((disk.total - disk.free) / disk.total) * 100) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className={card}>
|
||||||
|
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||||
|
<div><dt className="text-xs text-[var(--r-sub)]">Running for</dt><dd>{formatUptime(data.uptimeSeconds)}</dd></div>
|
||||||
|
<div><dt className="text-xs text-[var(--r-sub)]">Database</dt><dd>{formatBytes(disk.db)}</dd></div>
|
||||||
|
<div><dt className="text-xs text-[var(--r-sub)]">Snapshots</dt><dd>{formatBytes(disk.backups)}</dd></div>
|
||||||
|
<div><dt className="text-xs text-[var(--r-sub)]">Disk free</dt><dd>{formatBytes(disk.free)}{usedPct != null ? ` (${usedPct}% used)` : ''}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p className="mt-3 text-xs text-[var(--r-sub)]">Node {data.node} · IQ Bible {data.iqConfigured ? 'connected' : 'not set up'} · data in {data.dataDir}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={card}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">Snapshots</h3>
|
||||||
|
<button type="button" onClick={backupNow} disabled={busy} className={btn}>{busy && !restoring ? 'Working…' : 'Back up now'}</button>
|
||||||
|
</div>
|
||||||
|
{!backups.enabled && <p className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Automatic snapshots are turned off (BACKUP_DISABLED).</p>}
|
||||||
|
{stale && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{backups.latest ? `The last snapshot is ${ago(backups.latest.at)}.` : 'There are no snapshots yet.'} One should be made every day.</p>}
|
||||||
|
{data.backupFailures.length > 0 && <p className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Last failure: {data.backupFailures[0].detail} ({ago(data.backupFailures[0].at)})</p>}
|
||||||
|
{backups.snapshots.length > 0 && (
|
||||||
|
<ul className="mt-2 divide-y divide-[var(--r-line)] text-sm">
|
||||||
|
{backups.snapshots.map((s) => (
|
||||||
|
<li key={s.name} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||||
|
<span className="min-w-0"><span className="break-all">{s.name}</span> <span className="text-xs text-[var(--r-sub)]">· {formatBytes(s.bytes)} · {ago(s.at)}</span></span>
|
||||||
|
<span className="flex gap-2">
|
||||||
|
<a href={adminSnapshotUrl(s.name)} download className={btn}>Download</a>
|
||||||
|
<button type="button" onClick={() => { setRestoring(s.name); setTyped(''); setMessage(''); }} className={btn}>Restore…</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-[var(--r-sub)]">Keeps the newest {backups.keep}. A snapshot holds every account’s data.</p>
|
||||||
|
{restoring && (
|
||||||
|
<div role="group" aria-label="Confirm restore" className="mt-3 space-y-2 rounded-xl border border-[var(--r-danger,#b42318)] p-3 text-sm">
|
||||||
|
<p>This replaces <strong>everyone’s</strong> data with what was saved in <span className="break-all">{restoring}</span>. Anything changed since then is lost, though what’s there now is saved as a new snapshot first. Type the snapshot’s name to go ahead.</p>
|
||||||
|
<input value={typed} onChange={(e) => setTyped(e.target.value)} aria-label="Snapshot name" placeholder={restoring}
|
||||||
|
className="w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={restore} disabled={busy || typed !== restoring} className="rounded-lg bg-[var(--r-danger,#b42318)] px-3 py-1.5 text-xs font-medium text-white disabled:opacity-40">{busy ? 'Restoring…' : 'Restore this snapshot'}</button>
|
||||||
|
<button type="button" onClick={() => setRestoring(null)} className={btn}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message && <p role="status" className="mt-2 text-xs text-[var(--r-sub)]">{message}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Log title="Failed sign-ins" empty="None since the server last started." rows={data.signinFailures} />
|
||||||
|
<Log title="Failed notifications" empty="None since the server last started." rows={data.notifyFailures} />
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">The failure lists are kept in memory, so they start over when the server restarts.</p>
|
||||||
|
<button type="button" onClick={load} className={btn}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import AdminHealth, { formatBytes, formatUptime } from './AdminHealth.jsx';
|
||||||
|
|
||||||
|
const health = (over = {}) => ({
|
||||||
|
uptimeSeconds: 7200, node: 'v22', dataDir: '/data', iqConfigured: false,
|
||||||
|
disk: { db: 2048, backups: 4096, free: 5e9, total: 1e10 },
|
||||||
|
backups: { enabled: true, keep: 7, latest: { name: 'projects-2026-10-05-03-00.db', bytes: 2048, at: Date.now() - 3600000 }, snapshots: [{ name: 'projects-2026-10-05-03-00.db', bytes: 2048, at: Date.now() - 3600000 }] },
|
||||||
|
signinFailures: [{ at: Date.now() - 60000, kind: 'signin', detail: 'Wrong password for a@x.test from 1.2.3.4' }],
|
||||||
|
notifyFailures: [], backupFailures: [], ...over,
|
||||||
|
});
|
||||||
|
const mockFetch = (routes) => vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
||||||
|
const key = `${opts?.method ?? 'GET'} ${String(url).replace(/^.*\/api/, '')}`;
|
||||||
|
const r = routes[key] ?? routes.default;
|
||||||
|
return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body };
|
||||||
|
}));
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
test('formats sizes and times plainly', () => {
|
||||||
|
expect(formatBytes(2048)).toBe('2.0 KB');
|
||||||
|
expect(formatBytes(5e9)).toBe('4.7 GB');
|
||||||
|
expect(formatBytes(null)).toBe('unknown');
|
||||||
|
expect(formatUptime(7200)).toBe('2 hours');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows disk use, snapshots and sign-in failures', async () => {
|
||||||
|
mockFetch({ 'GET /admin/health': { body: health() } });
|
||||||
|
render(<AdminHealth />);
|
||||||
|
expect(await screen.findByText('2 hours')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Wrong password for a@x.test/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('None since the server last started.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'Download' })).toHaveAttribute('href', expect.stringContaining('/admin/backups/projects-2026-10-05-03-00.db'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns when the newest snapshot is old', async () => {
|
||||||
|
const old = Date.now() - 4 * 86400000;
|
||||||
|
mockFetch({ 'GET /admin/health': { body: health({ backups: { enabled: true, keep: 7, latest: { name: 'x', bytes: 1, at: old }, snapshots: [] } }) } });
|
||||||
|
render(<AdminHealth />);
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/last snapshot is 4 days ago/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoring needs the snapshot name typed first', async () => {
|
||||||
|
const name = 'projects-2026-10-05-03-00.db';
|
||||||
|
mockFetch({ 'GET /admin/health': { body: health() }, [`POST /admin/backups/${name}/restore`]: { body: { restored: name, safetyCopy: 'projects-new.db' } } });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AdminHealth />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Restore…' }));
|
||||||
|
const go = screen.getByRole('button', { name: 'Restore this snapshot' });
|
||||||
|
expect(go).toBeDisabled();
|
||||||
|
await user.type(screen.getByLabelText('Snapshot name'), name);
|
||||||
|
expect(go).toBeEnabled();
|
||||||
|
await user.click(go);
|
||||||
|
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Restored projects-2026-10-05-03-00.db'));
|
||||||
|
expect(fetch.mock.calls.some(([u, o]) => o?.method === 'POST' && JSON.parse(o.body).confirm === name)).toBe(true);
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ import { loadProjectById } from '../utils/projectStorage.js';
|
|||||||
export default function CommandPalette({ onClose }) {
|
export default function CommandPalette({ onClose }) {
|
||||||
const {
|
const {
|
||||||
readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
|
readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
|
||||||
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans,
|
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans, openPrayer,
|
||||||
openBibleReader, requestReaderSearch, openBackup,
|
openBibleReader, requestReaderSearch, openBackup,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -40,7 +40,7 @@ export default function CommandPalette({ onClose }) {
|
|||||||
// 2. Pages and actions
|
// 2. Pages and actions
|
||||||
const pages = [
|
const pages = [
|
||||||
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
|
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
|
||||||
['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
|
['Prayer journal', 'Requests, answers and the verses behind them', openPrayer], ['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
|
||||||
['Back up my data', 'Download everything as a file', openBackup],
|
['Back up my data', 'Download everything as a file', openBackup],
|
||||||
];
|
];
|
||||||
const lower = q.toLowerCase();
|
const lower = q.toLowerCase();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const BY_PAGE = {
|
|||||||
['G', 'Pick a book and chapter'],
|
['G', 'Pick a book and chapter'],
|
||||||
['B', 'Bookmark the selected verse'],
|
['B', 'Bookmark the selected verse'],
|
||||||
['C', 'Compare translations'],
|
['C', 'Compare translations'],
|
||||||
|
['M', 'Map this chapter’s cross-references (needs IQ Bible)'],
|
||||||
['H', 'Your highlights'],
|
['H', 'Your highlights'],
|
||||||
[',', 'Reading settings'],
|
[',', 'Reading settings'],
|
||||||
['F', 'Hide or show the controls'],
|
['F', 'Hide or show the controls'],
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// The cross-reference map for a chapter (from IQ Bible's reference data): the chapter in the middle, the chapters
|
||||||
|
// it is linked to around it, in Bible order. Heavier lines mean more verse-to-verse links. Tap a chapter to see how
|
||||||
|
// they connect, open it, or move the map's centre there. A list below says the same in words.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Sheet from './Sheet.jsx';
|
||||||
|
import { useIq } from '../../hooks/useIq.js';
|
||||||
|
import { bookOptions } from '../../utils/bibleUtils.jsx';
|
||||||
|
import { chapterId, layoutMap, linkedChapters, parseGraph } from '../../utils/xrefMap.js';
|
||||||
|
|
||||||
|
const SIZE = 700;
|
||||||
|
const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
|
||||||
|
|
||||||
|
export default function CrossRefMap({ open, onClose, book, chapter, onOpenChapter }) {
|
||||||
|
const [center, setCenter] = useState({ book, chapter });
|
||||||
|
const [depth, setDepth] = useState(1);
|
||||||
|
const [picked, setPicked] = useState(null);
|
||||||
|
useEffect(() => { if (open) { setCenter({ book, chapter }); setPicked(null); } }, [open, book, chapter]);
|
||||||
|
|
||||||
|
const { status, data, error } = useIq(open ? 'cross-references/graph' : null, { book: center.book, chapter: center.chapter, depth });
|
||||||
|
const centerId = chapterId(center.book, center.chapter);
|
||||||
|
const graph = useMemo(() => parseGraph(data), [data]);
|
||||||
|
const map = useMemo(() => layoutMap(graph, centerId, SIZE), [graph, centerId]);
|
||||||
|
const linked = useMemo(() => linkedChapters(graph, centerId), [graph, centerId]);
|
||||||
|
const byId = new Map(map.nodes.map((n) => [n.id, n]));
|
||||||
|
const sel = picked ? byId.get(picked) : null;
|
||||||
|
const selLinks = sel ? graph.edges.filter((e) => (e.source === centerId && e.target === sel.id) || (e.target === centerId && e.source === sel.id)) : [];
|
||||||
|
const label = (n) => `${bookName(n.book)} ${n.chapter}`;
|
||||||
|
const hot = new Set([...map.nodes].filter((n) => n.id !== centerId).sort((a, b) => b.weight - a.weight).slice(0, 14).map((n) => n.id)); // labelled on the picture
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} title={`Cross-reference map · ${bookName(center.book)} ${center.chapter}`} label="Cross-reference map">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-[var(--r-sub)]">
|
||||||
|
<span role="group" aria-label="How far to follow the links" className="flex overflow-hidden rounded-full border border-[var(--r-line)]">
|
||||||
|
{[[1, 'Directly linked'], [2, 'And the ones they link to']].map(([d, name]) => (
|
||||||
|
<button key={d} type="button" aria-pressed={depth === d} onClick={() => { setDepth(d); setPicked(null); }}
|
||||||
|
className={`px-3 py-1 ${depth === d ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'hover:bg-black/5'}`}>{name}</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{(center.book !== book || center.chapter !== chapter) && (
|
||||||
|
<button type="button" onClick={() => { setCenter({ book, chapter }); setPicked(null); }} className="text-[var(--r-accent)] hover:underline">Back to {bookName(book)} {chapter}</button>
|
||||||
|
)}
|
||||||
|
<span>Lines are heavier where there are more verse links.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'loading' && <p className="text-sm text-[var(--r-sub)]">Drawing the map…</p>}
|
||||||
|
{status === 'error' && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||||
|
{status === 'empty' && <p className="text-sm text-[var(--r-sub)]">No cross-references are recorded for this chapter.</p>}
|
||||||
|
{status === 'ready' && map.nodes.length > 1 && (
|
||||||
|
<>
|
||||||
|
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} role="img" aria-label={`Map of ${linked.length} chapters linked to ${bookName(center.book)} ${center.chapter}`} className="mx-auto block w-full max-w-[34rem]">
|
||||||
|
{map.edges.map((e) => <path key={`${e.source}-${e.target}`} d={e.path} fill="none" stroke="currentColor" strokeWidth={e.width} opacity={picked && (e.source === picked || e.target === picked) ? 0.9 : e.opacity} className="text-[var(--r-text)]" />)}
|
||||||
|
{map.nodes.map((n) => (
|
||||||
|
<g key={n.id} onClick={() => setPicked(n.id === picked ? null : n.id)} className="cursor-pointer" role="button" tabIndex={0} aria-label={`${label(n)}, ${n.weight} links`}
|
||||||
|
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setPicked(n.id === picked ? null : n.id); } }}>
|
||||||
|
<circle cx={n.x} cy={n.y} r={n.r + (n.id === picked ? 3 : 0)} fill={n.color} stroke={n.id === picked || n.id === centerId ? 'currentColor' : 'none'} strokeWidth="2" className="text-[var(--r-text)]" />
|
||||||
|
{(n.id === centerId || n.id === picked || hot.has(n.id)) && (
|
||||||
|
<text x={n.x} y={n.y + (n.y < SIZE / 2 ? -n.r - 5 : n.r + 14)} textAnchor="middle" fontSize={n.id === centerId ? 18 : 13} fontWeight={n.id === centerId ? 700 : 500} fill="currentColor" className="text-[var(--r-text)]">{label(n)}</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{graph.truncated && <p className="mt-1 text-center text-[11px] text-[var(--r-sub)]">A large map: only the first part is shown.</p>}
|
||||||
|
|
||||||
|
{sel && (
|
||||||
|
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3 text-sm" role="status">
|
||||||
|
<p className="font-medium">{label(sel)}</p>
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">{selLinks.length ? `${selLinks.reduce((n, e) => n + e.weight, 0)} verse links with ${bookName(center.book)} ${center.chapter}` : `${sel.ring > 1 ? 'Linked through another chapter' : 'Linked'}`}</p>
|
||||||
|
<div className="mt-2 flex gap-3 text-xs">
|
||||||
|
<button type="button" onClick={() => { onOpenChapter(sel.book, sel.chapter); }} className="font-medium text-[var(--r-accent)] hover:underline">Read {label(sel)}</button>
|
||||||
|
{sel.id !== centerId && <button type="button" onClick={() => { setCenter({ book: sel.book, chapter: sel.chapter }); setPicked(null); }} className="hover:underline">Put it in the middle</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Most linked to {bookName(center.book)} {center.chapter}</h3>
|
||||||
|
<ol className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-2">
|
||||||
|
{linked.slice(0, 20).map((c) => (
|
||||||
|
<li key={c.id} className="flex items-baseline justify-between gap-2">
|
||||||
|
<button type="button" onClick={() => setPicked(c.id)} className="truncate text-left hover:underline">{bookName(c.book)} {c.chapter}</button>
|
||||||
|
<span className="shrink-0 text-xs text-[var(--r-sub)]">{c.weight}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="mt-4 text-[10px] text-[var(--r-sub)]">Cross-references via IQ Bible.</p>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import CrossRefMap from './CrossRefMap.jsx';
|
||||||
|
import { resetIq } from '../../utils/iqbible.js';
|
||||||
|
|
||||||
|
let routes;
|
||||||
|
let asked;
|
||||||
|
beforeEach(() => {
|
||||||
|
resetIq();
|
||||||
|
asked = [];
|
||||||
|
routes = {};
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url) => {
|
||||||
|
asked.push(String(url));
|
||||||
|
const hit = routes[String(url)];
|
||||||
|
return Promise.resolve(hit ? { ok: true, status: 200, json: async () => hit } : { ok: false, status: 404, json: async () => ({ error: 'nf' }) });
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
const graph = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'GEN.1', book: 'GEN', name_en: 'Genesis', chapter: 1 }, { id: 'PSA.104', book: 'PSA', name_en: 'Psalms', chapter: 104 },
|
||||||
|
{ id: 'JHN.1', book: 'JHN', name_en: 'John', chapter: 1 }, { id: 'COL.1', book: 'COL', name_en: 'Colossians', chapter: 1 },
|
||||||
|
],
|
||||||
|
edges: [{ source: 'GEN.1', target: 'PSA.104', weight: 4 }, { source: 'GEN.1', target: 'JHN.1', weight: 9 }, { source: 'GEN.1', target: 'COL.1', weight: 3 }],
|
||||||
|
};
|
||||||
|
const url = (book, chapter, depth = 1) => `/api/iq/cross-references/graph?book=${book}&chapter=${chapter}&depth=${depth}`;
|
||||||
|
|
||||||
|
test('nothing is fetched until the map is opened; then the linked chapters are drawn and listed strongest first', async () => {
|
||||||
|
const { rerender } = render(<CrossRefMap open={false} onClose={() => {}} book="GEN" chapter={1} onOpenChapter={() => {}} />);
|
||||||
|
expect(asked).toEqual([]);
|
||||||
|
routes[url('GEN', 1)] = graph;
|
||||||
|
rerender(<CrossRefMap open onClose={() => {}} book="GEN" chapter={1} onOpenChapter={() => {}} />);
|
||||||
|
expect(await screen.findByRole('img', { name: /Map of 3 chapters linked to Genesis 1/ })).toBeInTheDocument();
|
||||||
|
const items = screen.getAllByRole('listitem').map((li) => li.textContent);
|
||||||
|
expect(items[0]).toBe('John 19'); // strongest link first
|
||||||
|
expect(items[1]).toBe('Psalms 1044');
|
||||||
|
expect(screen.getByRole('button', { name: 'John 1, 9 links' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picking a chapter shows how strongly it links, and can open it or put it in the middle', async () => {
|
||||||
|
routes[url('GEN', 1)] = graph;
|
||||||
|
routes[url('JHN', 1)] = { nodes: [{ id: 'JHN.1', book: 'JHN', name_en: 'John', chapter: 1 }, { id: 'GEN.1', book: 'GEN', name_en: 'Genesis', chapter: 1 }], edges: [{ source: 'JHN.1', target: 'GEN.1', weight: 9 }] };
|
||||||
|
const onOpenChapter = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CrossRefMap open onClose={() => {}} book="GEN" chapter={1} onOpenChapter={onOpenChapter} />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'John 1, 9 links' }));
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('9 verse links with Genesis 1');
|
||||||
|
await user.click(screen.getByRole('button', { name: /read john 1/i }));
|
||||||
|
expect(onOpenChapter).toHaveBeenCalledWith('JHN', 1);
|
||||||
|
await user.click(screen.getByRole('button', { name: /put it in the middle/i }));
|
||||||
|
expect(await screen.findByText(/Cross-reference map · John 1/)).toBeInTheDocument();
|
||||||
|
expect(asked).toContain(url('JHN', 1));
|
||||||
|
await user.click(screen.getByRole('button', { name: /back to genesis 1/i }));
|
||||||
|
expect(await screen.findByText(/Cross-reference map · Genesis 1/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('following the links further asks for more, and a chapter with no links says so', async () => {
|
||||||
|
routes[url('GEN', 1)] = graph;
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { unmount } = render(<CrossRefMap open onClose={() => {}} book="GEN" chapter={1} onOpenChapter={() => {}} />);
|
||||||
|
await screen.findByRole('img');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'And the ones they link to' }));
|
||||||
|
expect(asked).toContain(url('GEN', 1, 2));
|
||||||
|
unmount();
|
||||||
|
render(<CrossRefMap open onClose={() => {}} book="OBA" chapter={1} onOpenChapter={() => {}} />);
|
||||||
|
expect(await screen.findByText(/No cross-references are recorded/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -156,7 +156,7 @@ function CompareTab({ book, chapter, verse, current }) {
|
|||||||
|
|
||||||
export default function VerseSheet({
|
export default function VerseSheet({
|
||||||
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
||||||
bookmarkColor, onToggleBookmark, onCycleColor, bookmarkNote = '', onSetBookmarkNote, onSetHighlightNote, studiesHere = [], onOpenStudy, onPray, onJump, onClose, request,
|
bookmarkColor, onToggleBookmark, onCycleColor, bookmarkNote = '', onSetBookmarkNote, onSetHighlightNote, studiesHere = [], onOpenStudy, onPray, onOpenMap, onJump, onClose, request,
|
||||||
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
|
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
|
||||||
}) {
|
}) {
|
||||||
const iqOn = useIqAvailable();
|
const iqOn = useIqAvailable();
|
||||||
@@ -291,6 +291,7 @@ export default function VerseSheet({
|
|||||||
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
|
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
|
||||||
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
|
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
|
||||||
{tab === 'original' && <OriginalTab words={words} />}
|
{tab === 'original' && <OriginalTab words={words} />}
|
||||||
|
{tab === 'refs' && onOpenMap && <button type="button" onClick={onOpenMap} className="mb-3 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Map this chapter’s links (M)</button>}
|
||||||
{tab === 'refs' && (
|
{tab === 'refs' && (
|
||||||
<RefsTab refs={crossRefs} loading={crossRefsLoading} active={preview} onPick={setPreview}
|
<RefsTab refs={crossRefs} loading={crossRefsLoading} active={preview} onPick={setPreview}
|
||||||
preview={preview && <RefPreview refItem={preview} translation={translation} onOpen={onJump} onClose={() => setPreview(null)} />} />
|
preview={preview && <RefPreview refItem={preview} translation={translation} onOpen={onJump} onClose={() => setPreview(null)} />} />
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Settings → Security: where you're signed in, with a way to sign the others out, and an optional alert for new sign-ins.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext.js';
|
||||||
|
import { listDevices, setLoginAlerts, signOutDevice, signOutOtherDevices } from '../../syncService.js';
|
||||||
|
|
||||||
|
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||||
|
const METHODS = { password: 'password', passkey: 'passkey', mfa: 'password and code', register: 'new account' };
|
||||||
|
|
||||||
|
export function whenText(at, now = Date.now()) {
|
||||||
|
const m = Math.round((now - at) / 60000);
|
||||||
|
if (m < 2) return 'just now';
|
||||||
|
if (m < 120) return `${m} minutes ago`;
|
||||||
|
if (m < 2880) return `${Math.round(m / 60)} hours ago`;
|
||||||
|
return `${Math.round(m / 1440)} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesSection() {
|
||||||
|
const { authUser, setAuthUser } = useApp();
|
||||||
|
const [devices, setDevices] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => { const r = await listDevices(); if (r.ok) setDevices(r.data); else setError(r.error ?? 'Could not load your devices.'); };
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const signOut = async (d) => {
|
||||||
|
if (!window.confirm(`Sign out ${d.label}?`)) return;
|
||||||
|
const r = await signOutDevice(d.id);
|
||||||
|
if (!r.ok && r.status !== 404) setError(r.error ?? 'Could not sign it out.');
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const signOutOthers = async () => {
|
||||||
|
if (!window.confirm('Sign out every other device? They’ll need to sign in again.')) return;
|
||||||
|
setBusy(true);
|
||||||
|
const r = await signOutOtherDevices();
|
||||||
|
setBusy(false);
|
||||||
|
if (!r.ok) setError(r.error ?? 'Could not sign them out.');
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const toggleAlerts = async (e) => {
|
||||||
|
setError('');
|
||||||
|
const r = await setLoginAlerts(e.target.checked);
|
||||||
|
if (r.ok) setAuthUser({ ...authUser, loginAlerts: r.data.loginAlerts }); else setError(r.error ?? 'Could not change that.');
|
||||||
|
};
|
||||||
|
const others = (devices ?? []).filter((d) => !d.current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Devices" className="space-y-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-[var(--r-text)]">Where you’re signed in</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--r-sub)]">If you don’t recognise one, sign it out and change your password.</p>
|
||||||
|
</div>
|
||||||
|
{devices === null && !error && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
|
||||||
|
{devices && (
|
||||||
|
<ul className="divide-y divide-[var(--r-line)] text-sm">
|
||||||
|
{devices.map((d) => (
|
||||||
|
<li key={d.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="font-medium">{d.label}</span>{d.current && <span className="ml-2 rounded-full bg-black/10 px-2 py-0.5 text-[11px]">This device</span>}
|
||||||
|
<span className="block text-xs text-[var(--r-sub)]">
|
||||||
|
{d.current ? 'Active now' : `Last active ${whenText(d.lastSeen)}`}{d.method ? ` · signed in with ${METHODS[d.method] ?? d.method}` : ''}{d.ip ? ` · ${d.ip}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{!d.current && <button type="button" onClick={() => signOut(d)} className={btn}>Sign out</button>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{others.length > 0 && <button type="button" onClick={signOutOthers} disabled={busy} className={btn}>{busy ? 'Signing out…' : `Sign out the other ${others.length === 1 ? 'device' : `${others.length} devices`}`}</button>}
|
||||||
|
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||||
|
<label className="flex items-start gap-2 border-t border-[var(--r-line)] pt-3 text-sm">
|
||||||
|
<input type="checkbox" checked={!!authUser?.loginAlerts} onChange={toggleAlerts} className="mt-1" />
|
||||||
|
<span>Tell me when someone signs in<span className="block text-xs text-[var(--r-sub)]">Sends a message to your notification topic (set up under Notifications) each time a device signs in.</span></span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Settings → Security: sign in with Face ID, a fingerprint or a security key instead of typing a password.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { listPasskeys, passkeyRemove, passkeyRename } from '../../syncService.js';
|
||||||
|
import { addPasskeyHere, passkeysSupported } from '../../utils/passkeys.js';
|
||||||
|
import { useToast } from '../Toasts.jsx';
|
||||||
|
|
||||||
|
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||||
|
const day = (t) => new Date(t).toLocaleDateString();
|
||||||
|
|
||||||
|
export default function PasskeysSection() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [list, setList] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [editing, setEditing] = useState(null); // { id, name }
|
||||||
|
const supported = passkeysSupported();
|
||||||
|
|
||||||
|
useEffect(() => { listPasskeys().then((r) => setList(r.ok ? r.data : [])); }, []);
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
setBusy(true); setError('');
|
||||||
|
const r = await addPasskeyHere();
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) { setList(r.data); toast('Passkey added. You can now sign in with it.'); } else setError(r.error ?? 'Could not add a passkey.');
|
||||||
|
};
|
||||||
|
const remove = async (p) => {
|
||||||
|
if (!window.confirm(`Remove the passkey “${p.name}”? You can still sign in with your password.`)) return;
|
||||||
|
const r = await passkeyRemove(p.id);
|
||||||
|
if (r.ok) setList(r.data); else setError(r.error ?? 'Could not remove it.');
|
||||||
|
};
|
||||||
|
const rename = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const r = await passkeyRename(editing.id, editing.name);
|
||||||
|
if (r.ok) { setList(r.data); setEditing(null); } else setError(r.error ?? 'Could not rename it.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Passkeys" className="space-y-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-[var(--r-text)]">Passkeys</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--r-sub)]">Sign in with Face ID, a fingerprint or a security key, with nothing to type. A passkey is checked by your device, so it replaces the authenticator code too.</p>
|
||||||
|
</div>
|
||||||
|
{!supported && <p className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)]">This browser or address can’t use passkeys. They need a recent browser and an https:// address.</p>}
|
||||||
|
{list?.length > 0 && (
|
||||||
|
<ul className="divide-y divide-[var(--r-line)] text-sm">
|
||||||
|
{list.map((p) => (
|
||||||
|
<li key={p.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||||
|
{editing?.id === p.id ? (
|
||||||
|
<form onSubmit={rename} className="flex flex-1 gap-2">
|
||||||
|
<input aria-label="Passkey name" autoFocus value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} maxLength={60}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1.5 text-sm" />
|
||||||
|
<button type="submit" className={btn}>Save</button>
|
||||||
|
<button type="button" onClick={() => setEditing(null)} className={btn}>Cancel</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="min-w-0"><span className="font-medium">{p.name}</span>
|
||||||
|
<span className="block text-xs text-[var(--r-sub)]">Added {day(p.createdAt)} · {p.lastUsedAt ? `last used ${day(p.lastUsedAt)}` : 'not used yet'}</span></span>
|
||||||
|
<span className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setEditing({ id: p.id, name: p.name })} className={btn}>Rename</button>
|
||||||
|
<button type="button" onClick={() => remove(p)} className={btn}>Remove</button>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{list && list.length === 0 && supported && <p className="text-sm text-[var(--r-sub)]">No passkeys yet.</p>}
|
||||||
|
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||||
|
{supported && <button type="button" onClick={add} disabled={busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{busy ? 'Waiting for your device…' : 'Add a passkey on this device'}</button>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
const app = { authUser: { id: 'u1', email: 'a@x.test', loginAlerts: false }, setAuthUser: vi.fn() };
|
||||||
|
vi.mock('../../context/AppContext.js', () => ({ useApp: () => app }));
|
||||||
|
const webauthn = vi.hoisted(() => ({ browserSupportsWebAuthn: vi.fn(() => true), startRegistration: vi.fn(), startAuthentication: vi.fn() }));
|
||||||
|
vi.mock('@simplewebauthn/browser', () => webauthn);
|
||||||
|
|
||||||
|
import PasskeysSection from './PasskeysSection.jsx';
|
||||||
|
import DevicesSection, { whenText } from './DevicesSection.jsx';
|
||||||
|
import { signInWithPasskey } from '../../utils/passkeys.js';
|
||||||
|
|
||||||
|
const routes = {};
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.keys(routes).forEach((k) => delete routes[k]);
|
||||||
|
app.setAuthUser.mockClear(); webauthn.browserSupportsWebAuthn.mockReturnValue(true);
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
||||||
|
const key = `${opts?.method ?? 'GET'} ${String(url).replace(/^.*\/api/, '')}`;
|
||||||
|
const r = routes[key] ?? { status: 404, body: { error: `no route ${key}` } };
|
||||||
|
return { ok: (r.status ?? 200) < 400, status: r.status ?? 200, json: async () => r.body };
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
const passkey = { id: 'k1', name: 'My phone', createdAt: 1_700_000_000_000, lastUsedAt: null };
|
||||||
|
|
||||||
|
test('lists passkeys and adds one through the device prompt', async () => {
|
||||||
|
routes['GET /passkeys'] = { body: [passkey] };
|
||||||
|
routes['POST /passkeys/options'] = { body: { challenge: 'c' } };
|
||||||
|
routes['POST /passkeys'] = { body: [passkey, { ...passkey, id: 'k2', name: 'Laptop' }] };
|
||||||
|
webauthn.startRegistration.mockResolvedValue({ id: 'k2' });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PasskeysSection />);
|
||||||
|
expect(await screen.findByText('My phone')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/not used yet/)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /Add a passkey on this device/ }));
|
||||||
|
expect(await screen.findByText('Laptop')).toBeInTheDocument();
|
||||||
|
expect(webauthn.startRegistration).toHaveBeenCalledWith({ optionsJSON: { challenge: 'c' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says so when the device prompt is cancelled', async () => {
|
||||||
|
routes['GET /passkeys'] = { body: [] };
|
||||||
|
routes['POST /passkeys/options'] = { body: { challenge: 'c' } };
|
||||||
|
webauthn.startRegistration.mockRejectedValue(Object.assign(new Error('x'), { name: 'NotAllowedError' }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PasskeysSection />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: /Add a passkey/ }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('cancelled or timed out');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renames and removes a passkey', async () => {
|
||||||
|
routes['GET /passkeys'] = { body: [passkey] };
|
||||||
|
routes['PATCH /passkeys/k1'] = { body: [{ ...passkey, name: 'Pixel' }] };
|
||||||
|
routes['DELETE /passkeys/k1'] = { body: [] };
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PasskeysSection />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Rename' }));
|
||||||
|
const input = screen.getByLabelText('Passkey name');
|
||||||
|
await user.clear(input); await user.type(input, 'Pixel'); await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
expect(await screen.findByText('Pixel')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Remove' }));
|
||||||
|
await waitFor(() => expect(screen.queryByText('Pixel')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides the button where passkeys cannot work', async () => {
|
||||||
|
webauthn.browserSupportsWebAuthn.mockReturnValue(false);
|
||||||
|
routes['GET /passkeys'] = { body: [] };
|
||||||
|
render(<PasskeysSection />);
|
||||||
|
expect(await screen.findByText(/can’t use passkeys/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /Add a passkey/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sign-in with a passkey asks the server, then the device, then the server again', async () => {
|
||||||
|
routes['POST /auth/passkey/options'] = { body: { challenge: 'c' } };
|
||||||
|
routes['POST /auth/passkey/verify'] = { body: { id: 'u1', email: 'a@x.test' } };
|
||||||
|
webauthn.startAuthentication.mockResolvedValue({ id: 'k1' });
|
||||||
|
expect(await signInWithPasskey()).toMatchObject({ ok: true, data: { email: 'a@x.test' } });
|
||||||
|
webauthn.startAuthentication.mockRejectedValue(Object.assign(new Error('x'), { name: 'NotAllowedError' }));
|
||||||
|
expect(await signInWithPasskey()).toMatchObject({ ok: false, cancelled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows devices, signs one out, and signs out the rest', async () => {
|
||||||
|
const devices = [
|
||||||
|
{ id: 'd1', current: true, label: 'Safari on macOS', method: 'password', ip: '1.1.1.1', lastSeen: Date.now() },
|
||||||
|
{ id: 'd2', current: false, label: 'Chrome on Windows', method: 'passkey', ip: '2.2.2.2', lastSeen: Date.now() - 3 * 86400000 },
|
||||||
|
];
|
||||||
|
routes['GET /sessions'] = { body: devices };
|
||||||
|
routes['DELETE /sessions/d2'] = { body: { ok: true } };
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<DevicesSection />);
|
||||||
|
expect(await screen.findByText('Chrome on Windows')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('This device')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/signed in with passkey/)).toBeInTheDocument();
|
||||||
|
routes['GET /sessions'] = { body: [devices[0]] };
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign out' }));
|
||||||
|
await waitFor(() => expect(screen.queryByText('Chrome on Windows')).not.toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('button', { name: /Sign out the other/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the sign-in alert switch reports a server refusal', async () => {
|
||||||
|
routes['GET /sessions'] = { body: [] };
|
||||||
|
routes['PUT /auth/login-alerts'] = { status: 400, body: { error: 'Set up a notification topic first (under Notifications).' } };
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<DevicesSection />);
|
||||||
|
await user.click(await screen.findByRole('checkbox'));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('notification topic');
|
||||||
|
expect(app.setAuthUser).not.toHaveBeenCalled();
|
||||||
|
routes['PUT /auth/login-alerts'] = { body: { loginAlerts: true } };
|
||||||
|
await user.click(screen.getByRole('checkbox'));
|
||||||
|
await waitFor(() => expect(app.setAuthUser).toHaveBeenCalledWith(expect.objectContaining({ loginAlerts: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('times read plainly', () => {
|
||||||
|
const now = 1_000_000_000_000;
|
||||||
|
expect(whenText(now - 30_000, now)).toBe('just now');
|
||||||
|
expect(whenText(now - 5 * 3600_000, now)).toBe('5 hours ago');
|
||||||
|
expect(whenText(now - 3 * 86400_000, now)).toBe('3 days ago');
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import AdminSignup from '../components/AdminSignup.jsx';
|
import AdminSignup from '../components/AdminSignup.jsx';
|
||||||
|
import AdminHealth from '../components/AdminHealth.jsx';
|
||||||
import PageShell from '../components/study/PageShell.jsx';
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
import { buildExportHtml } from '../utils/bibleUtils.jsx';
|
import { buildExportHtml } from '../utils/bibleUtils.jsx';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { authUser, goHome } = useApp();
|
const { authUser, goHome } = useApp();
|
||||||
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects' | 'signup'
|
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects' | 'signup' | 'health'
|
||||||
const [adminUsers, setAdminUsers] = useState([]);
|
const [adminUsers, setAdminUsers] = useState([]);
|
||||||
const [adminProjects, setAdminProjects] = useState([]);
|
const [adminProjects, setAdminProjects] = useState([]);
|
||||||
const [adminLoading, setAdminLoading] = useState(false);
|
const [adminLoading, setAdminLoading] = useState(false);
|
||||||
@@ -84,9 +85,14 @@ export default function AdminPage() {
|
|||||||
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'signup' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'signup' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
Sign-ups
|
Sign-ups
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => setAdminTab('health')}
|
||||||
|
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'health' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
|
Health
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{adminTab === 'signup' && <AdminSignup />}
|
{adminTab === 'signup' && <AdminSignup />}
|
||||||
|
{adminTab === 'health' && <AdminHealth />}
|
||||||
|
|
||||||
{adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
|
{adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
|
||||||
{adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>}
|
{adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
|
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
|
||||||
|
import { passkeysSupported, signInWithPasskey } from '../utils/passkeys.js';
|
||||||
import ResetPasswordForm from '../components/ResetPasswordForm.jsx';
|
import ResetPasswordForm from '../components/ResetPasswordForm.jsx';
|
||||||
import { getSignupMode, loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
import { getSignupMode, loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
||||||
|
|
||||||
@@ -124,6 +125,15 @@ export default function AuthPage() {
|
|||||||
signIn(result.data);
|
signIn(result.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitPasskey = async () => {
|
||||||
|
setAuthError('');
|
||||||
|
setAuthBusy(true);
|
||||||
|
const result = await signInWithPasskey();
|
||||||
|
setAuthBusy(false);
|
||||||
|
if (result.ok) signIn(result.data);
|
||||||
|
else if (!result.cancelled) setAuthError(result.error ?? 'Passkey sign-in failed.');
|
||||||
|
};
|
||||||
|
|
||||||
const submitMfa = async (e) => {
|
const submitMfa = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
@@ -265,6 +275,10 @@ export default function AuthPage() {
|
|||||||
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{authMode === 'login' && passkeysSupported() && (
|
||||||
|
<button type="button" onClick={submitPasskey} disabled={authBusy}
|
||||||
|
className="mt-3 w-full rounded-full border border-[var(--r-line)] px-4 py-2.5 text-sm text-[var(--r-text)] hover:bg-black/5 disabled:opacity-50">Sign in with a passkey</button>
|
||||||
|
)}
|
||||||
{authMode === 'login' && (
|
{authMode === 'login' && (
|
||||||
<button type="button" onClick={() => { setAuthMode('forgot'); setAuthError(''); }}
|
<button type="button" onClick={() => { setAuthMode('forgot'); setAuthError(''); }}
|
||||||
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Forgot your password?</button>
|
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Forgot your password?</button>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
|
|||||||
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
|
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
|
||||||
import CompareSheet from '../components/reader/CompareSheet.jsx';
|
import CompareSheet from '../components/reader/CompareSheet.jsx';
|
||||||
import SearchSheet from '../components/reader/SearchSheet.jsx';
|
import SearchSheet from '../components/reader/SearchSheet.jsx';
|
||||||
|
import CrossRefMap from '../components/reader/CrossRefMap.jsx';
|
||||||
|
import { useIqAvailable } from '../hooks/useIq.js';
|
||||||
import MarksSheet from '../components/reader/MarksSheet.jsx';
|
import MarksSheet from '../components/reader/MarksSheet.jsx';
|
||||||
import { useToast } from '../components/Toasts.jsx';
|
import { useToast } from '../components/Toasts.jsx';
|
||||||
import { HIGHLIGHT_COLORS, colorTitle } from '../utils/highlightColors.js';
|
import { HIGHLIGHT_COLORS, colorTitle } from '../utils/highlightColors.js';
|
||||||
@@ -107,7 +109,8 @@ export default function ReaderPage() {
|
|||||||
const parallelChapter = useParallelChapter(parallelId, readerBookAbbrev, readerChapter);
|
const parallelChapter = useParallelChapter(parallelId, readerBookAbbrev, readerChapter);
|
||||||
const parallelSource = READER_TRANSLATIONS.find((t) => t.id === parallelId);
|
const parallelSource = READER_TRANSLATIONS.find((t) => t.id === parallelId);
|
||||||
|
|
||||||
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'highlights' | 'audio'
|
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'highlights' | 'audio' | 'xmap'
|
||||||
|
const iqOn = useIqAvailable();
|
||||||
const [chromeVisible, setChromeVisible] = useState(true);
|
const [chromeVisible, setChromeVisible] = useState(true);
|
||||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||||
const [selectionPicker, setSelectionPicker] = useState(null);
|
const [selectionPicker, setSelectionPicker] = useState(null);
|
||||||
@@ -284,6 +287,7 @@ export default function ReaderPage() {
|
|||||||
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
|
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
|
||||||
else if (e.key === ',') { e.preventDefault(); openSheet('settings'); }
|
else if (e.key === ',') { e.preventDefault(); openSheet('settings'); }
|
||||||
else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); openSheet('compare'); }
|
else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); openSheet('compare'); }
|
||||||
|
else if ((e.key === 'm' || e.key === 'M') && iqOn) { e.preventDefault(); openSheet('xmap'); }
|
||||||
else if (e.key === 'h' || e.key === 'H') { e.preventDefault(); openSheet('highlights'); }
|
else if (e.key === 'h' || e.key === 'H') { e.preventDefault(); openSheet('highlights'); }
|
||||||
else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v);
|
else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v);
|
||||||
else if ((e.key === 'b' || e.key === 'B') && selectedKey) {
|
else if ((e.key === 'b' || e.key === 'B') && selectedKey) {
|
||||||
@@ -575,7 +579,7 @@ export default function ReaderPage() {
|
|||||||
crossRefsLoading={readerCrossRefsLoading}
|
crossRefsLoading={readerCrossRefsLoading}
|
||||||
onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
|
onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
|
||||||
bookmarkColor={readerBookmarks[selectedKey]}
|
bookmarkColor={readerBookmarks[selectedKey]}
|
||||||
studiesHere={studiesHere} onOpenStudy={openStudyChunk} onPray={startPrayerFor}
|
studiesHere={studiesHere} onOpenStudy={openStudyChunk} onPray={startPrayerFor} onOpenMap={iqOn ? () => openSheet('xmap') : undefined}
|
||||||
bookmarkNote={readerBookmarkNotes?.[selectedKey] ?? ''} onSetBookmarkNote={(text) => setBookmarkNote(selectedKey, text)} onSetHighlightNote={setHighlightNote}
|
bookmarkNote={readerBookmarkNotes?.[selectedKey] ?? ''} onSetBookmarkNote={(text) => setBookmarkNote(selectedKey, text)} onSetHighlightNote={setHighlightNote}
|
||||||
onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))}
|
onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))}
|
||||||
onCycleColor={() => cycleBookmarkColor(selectedKey)}
|
onCycleColor={() => cycleBookmarkColor(selectedKey)}
|
||||||
@@ -641,6 +645,7 @@ export default function ReaderPage() {
|
|||||||
bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch}
|
bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch}
|
||||||
onJump={jump}
|
onJump={jump}
|
||||||
/>
|
/>
|
||||||
|
{iqOn && <CrossRefMap open={sheet === 'xmap'} onClose={closeSheet} book={readerBookAbbrev} chapter={readerChapter} onOpenChapter={(b, c) => jump(b, c, 1)} />}
|
||||||
<MarksSheet
|
<MarksSheet
|
||||||
open={sheet === 'bookmarks' || sheet === 'highlights'} onClose={closeSheet}
|
open={sheet === 'bookmarks' || sheet === 'highlights'} onClose={closeSheet}
|
||||||
initialTab={sheet === 'bookmarks' ? 'bookmarks' : 'highlights'}
|
initialTab={sheet === 'bookmarks' ? 'bookmarks' : 'highlights'}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { normalizeNtfyServer } from '../utils/ntfyServer.js';
|
||||||
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
|
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import PageShell from '../components/study/PageShell.jsx';
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
|
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
|
||||||
import DataSection from '../components/settings/DataSection.jsx';
|
import DataSection from '../components/settings/DataSection.jsx';
|
||||||
|
import PasskeysSection from '../components/settings/PasskeysSection.jsx';
|
||||||
|
import DevicesSection from '../components/settings/DevicesSection.jsx';
|
||||||
import { COMMENTARIES } from '../hooks/useVerseStudy.js';
|
import { COMMENTARIES } from '../hooks/useVerseStudy.js';
|
||||||
import { READER_THEMES } from '../components/reader/readerThemes.js';
|
import { READER_THEMES } from '../components/reader/readerThemes.js';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +20,7 @@ import {
|
|||||||
} from '../syncService.js';
|
} from '../syncService.js';
|
||||||
import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js';
|
import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js';
|
||||||
|
|
||||||
|
const SETTINGS_TABS = [['account', 'Account'], ['prefs', 'Preferences'], ['notify', 'Notifications'], ['security', 'Security'], ['data', 'Your data']];
|
||||||
const FONT_SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
|
const FONT_SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
|
||||||
const selectCls = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
const selectCls = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
|
|
||||||
@@ -61,9 +65,11 @@ export default function SettingsPage() {
|
|||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
// "Back up my data" from the ⌘K palette lands on that section
|
// "Back up my data" from the ⌘K palette lands on that section
|
||||||
|
const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsFocus) return;
|
if (!settingsFocus) return;
|
||||||
document.getElementById(settingsFocus)?.scrollIntoView({ block: 'start' });
|
if (settingsFocus === 'backup') setTab('data');
|
||||||
|
window.setTimeout(() => document.getElementById(settingsFocus)?.scrollIntoView({ block: 'start' }), 0);
|
||||||
clearSettingsFocus();
|
clearSettingsFocus();
|
||||||
}, [settingsFocus]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [settingsFocus]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
|
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
|
||||||
@@ -177,7 +183,9 @@ export default function SettingsPage() {
|
|||||||
const [reminderTest, setReminderTest] = useState({ busy: false, ok: false, message: '' });
|
const [reminderTest, setReminderTest] = useState({ busy: false, ok: false, message: '' });
|
||||||
const testReminder = async () => {
|
const testReminder = async () => {
|
||||||
setReminderTest({ busy: true, ok: false, message: '' });
|
setReminderTest({ busy: true, ok: false, message: '' });
|
||||||
const result = await sendReminderTest({ ntfyTopic: reminderForm.ntfyTopic, ntfyUrl: reminderForm.ntfyUrl });
|
const server = normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic);
|
||||||
|
if (server.error) { setReminderTest({ busy: false, ok: false, message: server.error }); return; }
|
||||||
|
const result = await sendReminderTest({ ntfyTopic: reminderForm.ntfyTopic, ntfyUrl: server.value });
|
||||||
setReminderTest(result.ok
|
setReminderTest(result.ok
|
||||||
? { busy: false, ok: true, message: 'Sent. Check your ntfy app or the topic page in a browser.' }
|
? { busy: false, ok: true, message: 'Sent. Check your ntfy app or the topic page in a browser.' }
|
||||||
: { busy: false, ok: false, message: result.error ?? 'Could not send the test.' });
|
: { busy: false, ok: false, message: result.error ?? 'Could not send the test.' });
|
||||||
@@ -187,13 +195,16 @@ export default function SettingsPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setReminderError('');
|
setReminderError('');
|
||||||
setReminderBusy(true);
|
setReminderBusy(true);
|
||||||
const result = await updateReminderSettings({ ...reminderForm, reminderTz: Intl.DateTimeFormat().resolvedOptions().timeZone });
|
const server = normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic);
|
||||||
|
if (server.error) { setReminderBusy(false); setReminderError(server.error); return; }
|
||||||
|
const result = await updateReminderSettings({ ...reminderForm, ntfyUrl: server.value, reminderTz: Intl.DateTimeFormat().resolvedOptions().timeZone });
|
||||||
setReminderBusy(false);
|
setReminderBusy(false);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setReminderError(result.error ?? 'Failed to save reminder settings.');
|
setReminderError(result.error ?? 'Failed to save reminder settings.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAuthUser((u) => ({ ...u, ...result.data }));
|
setAuthUser((u) => ({ ...u, ...result.data }));
|
||||||
|
setReminderForm((f) => ({ ...f, ntfyUrl: server.value })); // shows the address as saved (with https:// added if it was missing)
|
||||||
setReminderSaved(true);
|
setReminderSaved(true);
|
||||||
window.setTimeout(() => setReminderSaved(false), 2500);
|
window.setTimeout(() => setReminderSaved(false), 2500);
|
||||||
};
|
};
|
||||||
@@ -225,13 +236,25 @@ export default function SettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<PageShell title="Account Settings" onBack={goHome} maxWidth="max-w-2xl">
|
<PageShell title="Account Settings" onBack={goHome} maxWidth="max-w-2xl">
|
||||||
<main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
|
<main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
|
||||||
|
<div role="tablist" aria-label="Settings sections" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
|
||||||
|
{SETTINGS_TABS.map(([id, label]) => (
|
||||||
|
<button key={id} type="button" role="tab" id={`settings-tab-${id}`} aria-selected={tab === id} aria-controls="settings-panel" onClick={() => setTab(id)}
|
||||||
|
className={`shrink-0 rounded-full px-4 py-1.5 text-sm transition ${tab === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" id="settings-panel" aria-labelledby={`settings-tab-${tab}`} className="space-y-5">
|
||||||
|
{tab === 'account' && (
|
||||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-2">
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-2">
|
||||||
<h2 className="text-base font-medium text-[var(--r-text)]">Account</h2>
|
<h2 className="text-base font-medium text-[var(--r-text)]">Account</h2>
|
||||||
<p className="text-sm text-[var(--r-sub)]">
|
<p className="text-sm text-[var(--r-sub)]">
|
||||||
Signed in as <span className="font-medium text-[var(--r-text)]">{authUser.email}</span>
|
Signed in as <span className="font-medium text-[var(--r-text)]">{authUser.email}</span>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'prefs' && (
|
||||||
<section aria-label="Preferences" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
|
<section aria-label="Preferences" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
|
||||||
<h2 className="text-base font-medium">Preferences</h2>
|
<h2 className="text-base font-medium">Preferences</h2>
|
||||||
<p className="mb-4 mt-1 text-sm text-[var(--r-sub)]">How the app looks and reads. Study projects always use the Berean Standard Bible.</p>
|
<p className="mb-4 mt-1 text-sm text-[var(--r-sub)]">How the app looks and reads. Study projects always use the Berean Standard Bible.</p>
|
||||||
@@ -275,7 +298,9 @@ export default function SettingsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</PrefRow>
|
</PrefRow>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'security' && (
|
||||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-medium text-[var(--r-text)]">Change password</h2>
|
<h2 className="text-base font-medium text-[var(--r-text)]">Change password</h2>
|
||||||
@@ -327,7 +352,9 @@ export default function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'account' && (
|
||||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-medium text-[var(--r-text)]">Podcast / show name</h2>
|
<h2 className="text-base font-medium text-[var(--r-text)]">Podcast / show name</h2>
|
||||||
@@ -355,9 +382,13 @@ export default function SettingsPage() {
|
|||||||
</form>
|
</form>
|
||||||
{podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
|
{podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'data' && (
|
||||||
<DataSection />
|
<DataSection />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'prefs' && (
|
||||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>
|
<h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>
|
||||||
@@ -386,7 +417,9 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{journalError && <p className="text-sm text-[var(--r-danger,#b42318)]">{journalError}</p>}
|
{journalError && <p className="text-sm text-[var(--r-danger,#b42318)]">{journalError}</p>}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'notify' && (
|
||||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-medium text-[var(--r-text)]">Reading reminders</h2>
|
<h2 className="text-base font-medium text-[var(--r-text)]">Reading reminders</h2>
|
||||||
@@ -394,7 +427,7 @@ export default function SettingsPage() {
|
|||||||
Get a daily nudge via{' '}
|
Get a daily nudge via{' '}
|
||||||
<a href="https://ntfy.sh" target="_blank" rel="noreferrer" className="text-[var(--r-accent)] underline">ntfy.sh</a>
|
<a href="https://ntfy.sh" target="_blank" rel="noreferrer" className="text-[var(--r-accent)] underline">ntfy.sh</a>
|
||||||
{' '}— a free push-notification service. Install the ntfy app (or open{' '}
|
{' '}— a free push-notification service. Install the ntfy app (or open{' '}
|
||||||
<span className="font-mono text-xs">{(reminderForm.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '')}/{reminderForm.ntfyTopic || '<topic>'}</span>
|
<span className="font-mono text-xs">{(normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic).value || 'https://ntfy.sh')}/{reminderForm.ntfyTopic || '<topic>'}</span>
|
||||||
{' '}in a browser) and subscribe to the topic below to receive it.
|
{' '}in a browser) and subscribe to the topic below to receive it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -465,6 +498,12 @@ export default function SettingsPage() {
|
|||||||
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyUrl: e.target.value }))}
|
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyUrl: e.target.value }))}
|
||||||
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||||
/>
|
/>
|
||||||
|
{normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic).error && (
|
||||||
|
<span role="alert" className="mt-1 block text-xs text-[var(--r-danger,#b42318)]">
|
||||||
|
{normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic).error}{' '}
|
||||||
|
<button type="button" onClick={() => setReminderForm((f) => ({ ...f, ntfyUrl: '' }))} className="underline">Clear it</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
{reminderError && <p className="text-sm text-[var(--r-danger,#b42318)]">{reminderError}</p>}
|
{reminderError && <p className="text-sm text-[var(--r-danger,#b42318)]">{reminderError}</p>}
|
||||||
{reminderSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
|
{reminderSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
|
||||||
@@ -490,7 +529,9 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'security' && (
|
||||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-medium text-[var(--r-text)]">Two-factor authentication</h2>
|
<h2 className="text-base font-medium text-[var(--r-text)]">Two-factor authentication</h2>
|
||||||
@@ -592,6 +633,10 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
{tab === 'security' && <PasskeysSection />}
|
||||||
|
{tab === 'security' && <DevicesSection />}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -179,6 +179,19 @@ export async function loginUser(email, password) {
|
|||||||
return request('POST', '/auth/login', { email, password });
|
return request('POST', '/auth/login', { email, password });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passkeys and signed-in devices
|
||||||
|
export const passkeySignInOptions = () => request('POST', '/auth/passkey/options');
|
||||||
|
export const passkeySignInVerify = (response) => request('POST', '/auth/passkey/verify', response);
|
||||||
|
export const listPasskeys = () => request('GET', '/passkeys');
|
||||||
|
export const passkeyAddOptions = () => request('POST', '/passkeys/options');
|
||||||
|
export const passkeyAdd = (response, name) => request('POST', '/passkeys', { response, name });
|
||||||
|
export const passkeyRename = (id, name) => request('PATCH', `/passkeys/${encodeURIComponent(id)}`, { name });
|
||||||
|
export const passkeyRemove = (id) => request('DELETE', `/passkeys/${encodeURIComponent(id)}`);
|
||||||
|
export const listDevices = () => request('GET', '/sessions');
|
||||||
|
export const signOutDevice = (id) => request('DELETE', `/sessions/${id}`);
|
||||||
|
export const signOutOtherDevices = () => request('POST', '/sessions/sign-out-others');
|
||||||
|
export const setLoginAlerts = (enabled) => request('PUT', '/auth/login-alerts', { enabled });
|
||||||
|
|
||||||
/** Asks for a password-reset code to be sent to the account's notification topic. */
|
/** Asks for a password-reset code to be sent to the account's notification topic. */
|
||||||
export async function requestPasswordReset(email) {
|
export async function requestPasswordReset(email) {
|
||||||
return request('POST', '/auth/forgot', { email });
|
return request('POST', '/auth/forgot', { email });
|
||||||
@@ -275,6 +288,11 @@ export async function adminListUsers() {
|
|||||||
return request('GET', '/admin/users');
|
return request('GET', '/admin/users');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const adminGetHealth = () => request('GET', '/admin/health');
|
||||||
|
export const adminBackupNow = () => request('POST', '/admin/backups');
|
||||||
|
export const adminRestoreSnapshot = (name) => request('POST', `/admin/backups/${encodeURIComponent(name)}/restore`, { confirm: name });
|
||||||
|
export const adminSnapshotUrl = (name) => `${BASE}/admin/backups/${encodeURIComponent(name)}`;
|
||||||
|
|
||||||
export async function adminDeleteUser(id) {
|
export async function adminDeleteUser(id) {
|
||||||
return request('DELETE', `/admin/users/${id}`);
|
return request('DELETE', `/admin/users/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,23 @@ describe('Authentication', () => {
|
|||||||
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||||
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
|
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Settings is split into tabs, one group at a time', async () => {
|
||||||
|
stubApi({ session: user });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await openAccountMenu(u);
|
||||||
|
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||||
|
const tabs = await screen.findAllByRole('tab');
|
||||||
|
expect(tabs.map((t) => t.textContent)).toEqual(['Account', 'Preferences', 'Notifications', 'Security', 'Your data']);
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Two-factor authentication' })).not.toBeInTheDocument();
|
||||||
|
await u.click(screen.getByRole('tab', { name: 'Security' }));
|
||||||
|
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Two-factor authentication' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('heading', { name: 'Where you’re signed in' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByDisplayValue('My Show')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ describe('Chosen home page', () => {
|
|||||||
await renderApp();
|
await renderApp();
|
||||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||||
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||||
|
await u.click(await screen.findByRole('tab', { name: 'Preferences' }));
|
||||||
const prefs = within(await screen.findByRole('region', { name: 'Preferences' }));
|
const prefs = within(await screen.findByRole('region', { name: 'Preferences' }));
|
||||||
await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' }));
|
await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' }));
|
||||||
expect(localStorage.getItem('start-page')).toBe('plans');
|
expect(localStorage.getItem('start-page')).toBe('plans');
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ describe('Preference sync', () => {
|
|||||||
async function openSettings(u) {
|
async function openSettings(u) {
|
||||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||||
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||||
|
await u.click(await screen.findByRole('tab', { name: 'Preferences' }));
|
||||||
return screen.findByRole('region', { name: 'Preferences' });
|
return screen.findByRole('region', { name: 'Preferences' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// The optional "ntfy server" box in Settings is the address of a custom ntfy server. It is easy to put the topic
|
||||||
|
// name there by mistake, so this checks what was typed and says what to do instead of leaving a server-side error.
|
||||||
|
|
||||||
|
// -> { value, error }: value is the address to save ('' means the default, ntfy.sh); error is a plain-language message or ''
|
||||||
|
export function normalizeNtfyServer(input, topic = '') {
|
||||||
|
const typed = String(input ?? '').trim();
|
||||||
|
if (!typed) return { value: '', error: '' };
|
||||||
|
if (/^https?:\/\/\S+$/i.test(typed)) return { value: typed.replace(/\/+$/, ''), error: '' };
|
||||||
|
if (typed.toLowerCase() === String(topic).trim().toLowerCase()) {
|
||||||
|
return { value: '', error: 'That’s your topic name. The topic goes in the Topic box; leave this one empty to use ntfy.sh.' };
|
||||||
|
}
|
||||||
|
// an address typed without https://, like ntfy.example.com or 192.168.1.5:8080
|
||||||
|
if (/^[\w.-]+\.[A-Za-z]{2,}(:\d+)?(\/\S*)?$/.test(typed) || /^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(typed)) {
|
||||||
|
return { value: `https://${typed.replace(/\/+$/, '')}`, error: '' };
|
||||||
|
}
|
||||||
|
return { value: '', error: 'That doesn’t look like a server address. It’s only for your own ntfy server (for example https://ntfy.example.com). Leave it empty to use ntfy.sh.' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { normalizeNtfyServer } from './ntfyServer.js';
|
||||||
|
|
||||||
|
test('empty means the default server, and a full address is kept (without a trailing slash)', () => {
|
||||||
|
expect(normalizeNtfyServer('')).toEqual({ value: '', error: '' });
|
||||||
|
expect(normalizeNtfyServer(' ')).toEqual({ value: '', error: '' });
|
||||||
|
expect(normalizeNtfyServer('https://ntfy.example.com/')).toEqual({ value: 'https://ntfy.example.com', error: '' });
|
||||||
|
expect(normalizeNtfyServer('http://192.168.1.5:8080')).toEqual({ value: 'http://192.168.1.5:8080', error: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an address typed without https:// gets one', () => {
|
||||||
|
expect(normalizeNtfyServer('ntfy.example.com')).toEqual({ value: 'https://ntfy.example.com', error: '' });
|
||||||
|
expect(normalizeNtfyServer('ntfy.example.com:8443/')).toEqual({ value: 'https://ntfy.example.com:8443', error: '' });
|
||||||
|
expect(normalizeNtfyServer('192.168.1.5:8080')).toEqual({ value: 'https://192.168.1.5:8080', error: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a topic name put in the server box is recognised and explained', () => {
|
||||||
|
const r = normalizeNtfyServer('bible-study-project', 'Bible-Study-Project');
|
||||||
|
expect(r.value).toBe('');
|
||||||
|
expect(r.error).toMatch(/your topic name/);
|
||||||
|
expect(normalizeNtfyServer('my-topic', 'other').error).toMatch(/doesn’t look like a server address/);
|
||||||
|
expect(normalizeNtfyServer('two words').error).not.toBe('');
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Passkeys in the browser: ask the device (Face ID, fingerprint, security key) and pass the answer to the server.
|
||||||
|
import { browserSupportsWebAuthn, startAuthentication, startRegistration } from '@simplewebauthn/browser';
|
||||||
|
import { passkeyAdd, passkeyAddOptions, passkeySignInOptions, passkeySignInVerify } from '../syncService.js';
|
||||||
|
|
||||||
|
// Passkeys only work on https:// (or localhost), and only in browsers that support them.
|
||||||
|
export function passkeysSupported() {
|
||||||
|
try { return typeof window !== 'undefined' && window.isSecureContext !== false && browserSupportsWebAuthn(); } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passkeyMessage(err) {
|
||||||
|
const name = err?.name ?? '';
|
||||||
|
if (name === 'NotAllowedError' || name === 'AbortError') return 'That was cancelled or timed out. Try again.';
|
||||||
|
if (name === 'InvalidStateError') return 'This device already has a passkey for your account.';
|
||||||
|
if (name === 'SecurityError') return 'Passkeys need a secure (https) address.';
|
||||||
|
return err?.message ? `That didn’t work: ${err.message}` : 'That didn’t work. Try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves { ok, data (the signed-in user) } or { ok: false, error }. */
|
||||||
|
export async function signInWithPasskey() {
|
||||||
|
const opts = await passkeySignInOptions();
|
||||||
|
if (!opts.ok) return opts;
|
||||||
|
let response;
|
||||||
|
try { response = await startAuthentication({ optionsJSON: opts.data }); } catch (err) { return { ok: false, error: passkeyMessage(err), cancelled: err?.name === 'NotAllowedError' }; }
|
||||||
|
return passkeySignInVerify(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a passkey on this device. Resolves { ok, data (the updated list) } or { ok: false, error }. */
|
||||||
|
export async function addPasskeyHere(name) {
|
||||||
|
const opts = await passkeyAddOptions();
|
||||||
|
if (!opts.ok) return opts;
|
||||||
|
let response;
|
||||||
|
try { response = await startRegistration({ optionsJSON: opts.data }); } catch (err) { return { ok: false, error: passkeyMessage(err) }; }
|
||||||
|
return passkeyAdd(response, name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// The cross-reference map: a chapter in the middle, the chapters it is linked to around it in rings, in Bible order,
|
||||||
|
// joined by lines that are heavier where there are more verse-to-verse links. Pure layout code (no drawing) so it can be tested.
|
||||||
|
import { bookOptions } from './bibleUtils.jsx';
|
||||||
|
import { groupOfBook } from './bookCovers.js';
|
||||||
|
|
||||||
|
const ORDER = new Map(bookOptions.map((b, i) => [b.abbrev, i]));
|
||||||
|
const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
|
||||||
|
export const chapterId = (book, chapter) => `${book}.${chapter}`;
|
||||||
|
|
||||||
|
// The API's { nodes: [{ id, book, name_en, chapter }], edges: [{ source, target, weight }] } -> a clean copy
|
||||||
|
export function parseGraph(data) {
|
||||||
|
const nodes = (Array.isArray(data?.nodes) ? data.nodes : []).filter((n) => n && typeof n.id === 'string' && n.book && Number.isFinite(Number(n.chapter)))
|
||||||
|
.map((n) => ({ id: n.id, book: n.book, chapter: Number(n.chapter), name: n.name_en || bookName(n.book) }));
|
||||||
|
const have = new Set(nodes.map((n) => n.id));
|
||||||
|
const edges = (Array.isArray(data?.edges) ? data.edges : []).filter((e) => have.has(e?.source) && have.has(e?.target) && e.source !== e.target)
|
||||||
|
.map((e) => ({ source: e.source, target: e.target, weight: Math.max(1, Number(e.weight) || 1) }));
|
||||||
|
return { nodes, edges, truncated: !!data?.truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = (a, b) => (ORDER.get(a.book) ?? 99) - (ORDER.get(b.book) ?? 99) || a.chapter - b.chapter;
|
||||||
|
|
||||||
|
// How many chapter-to-chapter hops each chapter is from the centre (the centre is 0)
|
||||||
|
export function ringsFrom(nodes, edges, centerId) {
|
||||||
|
const next = new Map(nodes.map((n) => [n.id, []]));
|
||||||
|
for (const e of edges) { next.get(e.source)?.push(e.target); next.get(e.target)?.push(e.source); }
|
||||||
|
const ring = new Map([[centerId, 0]]);
|
||||||
|
const queue = [centerId];
|
||||||
|
while (queue.length) {
|
||||||
|
const id = queue.shift();
|
||||||
|
for (const other of next.get(id) ?? []) if (!ring.has(other)) { ring.set(other, ring.get(id) + 1); queue.push(other); }
|
||||||
|
}
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> { nodes: [{ ...node, x, y, ring, weight, color, r }], edges: [{ ...edge, path, width, opacity }], size }
|
||||||
|
// Positions are in a square of `size` units with the centre in the middle.
|
||||||
|
export function layoutMap(graph, centerId, size = 700) {
|
||||||
|
const ring = ringsFrom(graph.nodes, graph.edges, centerId);
|
||||||
|
const maxRing = Math.max(1, ...ring.values());
|
||||||
|
const radii = maxRing === 1 ? [0, 0.42] : maxRing === 2 ? [0, 0.24, 0.44] : [0, 0.18, 0.31, 0.44];
|
||||||
|
const weightOf = new Map(graph.nodes.map((n) => [n.id, 0]));
|
||||||
|
for (const e of graph.edges) { weightOf.set(e.source, weightOf.get(e.source) + e.weight); weightOf.set(e.target, weightOf.get(e.target) + e.weight); }
|
||||||
|
const maxWeight = Math.max(1, ...[...weightOf.entries()].filter(([id]) => id !== centerId).map(([, w]) => w));
|
||||||
|
|
||||||
|
const byRing = new Map();
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
const k = Math.min(ring.get(n.id) ?? maxRing, maxRing);
|
||||||
|
if (!byRing.has(k)) byRing.set(k, []);
|
||||||
|
byRing.get(k).push(n);
|
||||||
|
}
|
||||||
|
const placed = new Map();
|
||||||
|
for (const [k, list] of byRing) {
|
||||||
|
list.sort(canonical);
|
||||||
|
list.forEach((n, i) => {
|
||||||
|
const angle = k === 0 ? 0 : -Math.PI / 2 + (2 * Math.PI * i) / list.length;
|
||||||
|
const radius = (radii[k] ?? 0.44) * size;
|
||||||
|
const w = weightOf.get(n.id);
|
||||||
|
placed.set(n.id, {
|
||||||
|
...n, ring: k, weight: w, color: groupOfBook(n.book).colors[0],
|
||||||
|
x: size / 2 + Math.cos(angle) * radius, y: size / 2 + Math.sin(angle) * radius,
|
||||||
|
r: n.id === centerId ? 13 : 3 + 6 * Math.sqrt(w / maxWeight),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const maxEdge = Math.max(1, ...graph.edges.map((e) => e.weight));
|
||||||
|
const edges = graph.edges.map((e) => {
|
||||||
|
const a = placed.get(e.source);
|
||||||
|
const b = placed.get(e.target);
|
||||||
|
const toCenter = e.source === centerId || e.target === centerId;
|
||||||
|
// lines to the middle are straight; the others bow toward the middle so they don't cross the rim
|
||||||
|
const c = toCenter ? null : { x: size / 2 + (a.x + b.x - size) * 0.18, y: size / 2 + (a.y + b.y - size) * 0.18 };
|
||||||
|
return {
|
||||||
|
...e, path: c ? `M${a.x.toFixed(1)},${a.y.toFixed(1)} Q${c.x.toFixed(1)},${c.y.toFixed(1)} ${b.x.toFixed(1)},${b.y.toFixed(1)}` : `M${a.x.toFixed(1)},${a.y.toFixed(1)} L${b.x.toFixed(1)},${b.y.toFixed(1)}`,
|
||||||
|
width: 0.6 + 2.4 * (e.weight / maxEdge), opacity: toCenter ? 0.25 + 0.5 * (e.weight / maxEdge) : 0.08 + 0.3 * (e.weight / maxEdge),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { nodes: [...placed.values()], edges, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chapters linked to the centre, strongest first: [{ id, name, chapter, weight }]
|
||||||
|
export function linkedChapters(graph, centerId) {
|
||||||
|
const out = new Map();
|
||||||
|
for (const e of graph.edges) {
|
||||||
|
const other = e.source === centerId ? e.target : e.target === centerId ? e.source : null;
|
||||||
|
if (other) out.set(other, (out.get(other) ?? 0) + e.weight);
|
||||||
|
}
|
||||||
|
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||||
|
return [...out].map(([id, weight]) => ({ ...byId.get(id), weight })).sort((a, b) => b.weight - a.weight || canonical(a, b));
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { chapterId, layoutMap, linkedChapters, parseGraph, ringsFrom } from './xrefMap.js';
|
||||||
|
|
||||||
|
const raw = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'GEN.1', book: 'GEN', name_en: 'Genesis', chapter: 1 },
|
||||||
|
{ id: 'PSA.104', book: 'PSA', name_en: 'Psalms', chapter: 104 },
|
||||||
|
{ id: 'JHN.1', book: 'JHN', name_en: 'John', chapter: 1 },
|
||||||
|
{ id: 'COL.1', book: 'COL', name_en: 'Colossians', chapter: 1 },
|
||||||
|
{ id: 'junk' }, null,
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ source: 'GEN.1', target: 'PSA.104', weight: 4 },
|
||||||
|
{ source: 'GEN.1', target: 'JHN.1', weight: 9 },
|
||||||
|
{ source: 'JHN.1', target: 'COL.1', weight: 2 },
|
||||||
|
{ source: 'GEN.1', target: 'NOPE.9', weight: 1 }, // points at a chapter that isn't in the picture
|
||||||
|
{ source: 'GEN.1', target: 'GEN.1', weight: 5 }, // a chapter linked to itself
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('the API\'s graph is cleaned: bad nodes, dangling and self links are dropped', () => {
|
||||||
|
const g = parseGraph(raw);
|
||||||
|
expect(g.nodes.map((n) => n.id)).toEqual(['GEN.1', 'PSA.104', 'JHN.1', 'COL.1']);
|
||||||
|
expect(g.edges).toHaveLength(3);
|
||||||
|
expect(parseGraph(null)).toEqual({ nodes: [], edges: [], truncated: false });
|
||||||
|
expect(chapterId('GEN', 1)).toBe('GEN.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chapters are placed in rings by how many links away they are', () => {
|
||||||
|
const g = parseGraph(raw);
|
||||||
|
const ring = ringsFrom(g.nodes, g.edges, 'GEN.1');
|
||||||
|
expect([...ring]).toEqual([['GEN.1', 0], ['PSA.104', 1], ['JHN.1', 1], ['COL.1', 2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the layout puts the centre in the middle and the rest around it in Bible order', () => {
|
||||||
|
const g = parseGraph(raw);
|
||||||
|
const map = layoutMap(g, 'GEN.1', 700);
|
||||||
|
const at = Object.fromEntries(map.nodes.map((n) => [n.id, n]));
|
||||||
|
expect(at['GEN.1']).toMatchObject({ x: 350, y: 350, ring: 0 });
|
||||||
|
expect(at['PSA.104'].ring).toBe(1);
|
||||||
|
expect(at['COL.1'].ring).toBe(2);
|
||||||
|
// ring 1 is Psalms then John, starting at the top and going clockwise
|
||||||
|
expect(at['PSA.104'].y).toBeLessThan(350);
|
||||||
|
expect(at['JHN.1'].y).toBeGreaterThan(at['PSA.104'].y);
|
||||||
|
// every point is inside the square
|
||||||
|
expect(map.nodes.every((n) => n.x >= 0 && n.x <= 700 && n.y >= 0 && n.y <= 700)).toBe(true);
|
||||||
|
// heavier links are drawn heavier, and every line has a path
|
||||||
|
const heavy = map.edges.find((e) => e.weight === 9);
|
||||||
|
const light = map.edges.find((e) => e.weight === 2);
|
||||||
|
expect(heavy.width).toBeGreaterThan(light.width);
|
||||||
|
expect(map.edges.every((e) => e.path.startsWith('M'))).toBe(true);
|
||||||
|
expect(light.path).toContain('Q'); // a link that doesn't touch the middle bows toward it
|
||||||
|
expect(heavy.path).toContain('L');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the linked chapters are listed strongest first', () => {
|
||||||
|
const g = parseGraph(raw);
|
||||||
|
expect(linkedChapters(g, 'GEN.1').map((c) => [c.id, c.weight])).toEqual([['JHN.1', 9], ['PSA.104', 4]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a map with only the centre, or nothing linked, still lays out', () => {
|
||||||
|
const lone = { nodes: [{ id: 'GEN.1', book: 'GEN', name: 'Genesis', chapter: 1 }], edges: [] };
|
||||||
|
expect(layoutMap(lone, 'GEN.1').nodes).toHaveLength(1);
|
||||||
|
expect(layoutMap({ nodes: [], edges: [] }, 'GEN.1').nodes).toEqual([]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user