Add membership backend and signer login
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.local
|
*.local
|
||||||
|
data
|
||||||
|
server/data
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>L484 Vue Tailwind</title>
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="/images/small-logo.svg" type="image/svg+xml" />
|
||||||
|
<title>L484</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
260
package-lock.json
generated
260
package-lock.json
generated
@@ -9,7 +9,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"nostr-tools": "^2.23.3",
|
"applesauce-signers": "^5.1.0",
|
||||||
|
"nostr-tools": "^2.10.4",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
@@ -533,44 +534,62 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/ciphers": {
|
"node_modules/@noble/ciphers": {
|
||||||
"version": "2.1.1",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||||
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.19.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/curves": {
|
"node_modules/@noble/curves": {
|
||||||
"version": "2.0.1",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "2.0.1"
|
"@noble/hashes": "1.3.2"
|
||||||
},
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.0"
|
"node": ">= 16"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "2.0.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.0"
|
"node": ">= 16"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/secp256k1": {
|
||||||
|
"version": "1.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz",
|
||||||
|
"integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -935,36 +954,51 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@scure/base": {
|
"node_modules/@scure/base": {
|
||||||
"version": "2.0.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
|
||||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@scure/bip32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/curves": "~1.1.0",
|
||||||
|
"@noble/hashes": "~1.3.1",
|
||||||
|
"@scure/base": "~1.1.0"
|
||||||
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scure/bip32": {
|
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||||
"version": "2.0.1",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/curves": "2.0.1",
|
"@noble/hashes": "1.3.1"
|
||||||
"@noble/hashes": "2.0.1",
|
|
||||||
"@scure/base": "2.0.0"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scure/bip39": {
|
"node_modules/@scure/bip39": {
|
||||||
"version": "2.0.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
|
||||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "2.0.1",
|
"@noble/hashes": "~1.3.0",
|
||||||
"@scure/base": "2.0.0"
|
"@scure/base": "~1.1.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
@@ -1110,6 +1144,100 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/applesauce-core": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"hash-sum": "^2.0.0",
|
||||||
|
"nanoid": "^5.0.9",
|
||||||
|
"nostr-tools": "~2.19",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "lightning",
|
||||||
|
"url": "lightning:nostrudel@geyser.fund"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/applesauce-core/node_modules/nanoid": {
|
||||||
|
"version": "5.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
|
||||||
|
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || >=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/applesauce-core/node_modules/nostr-tools": {
|
||||||
|
"version": "2.19.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
|
||||||
|
"integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "^0.5.1",
|
||||||
|
"@noble/curves": "1.2.0",
|
||||||
|
"@noble/hashes": "1.3.1",
|
||||||
|
"@scure/base": "1.1.1",
|
||||||
|
"@scure/bip32": "1.3.1",
|
||||||
|
"@scure/bip39": "1.2.1",
|
||||||
|
"nostr-wasm": "0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/applesauce-signers": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/applesauce-signers/-/applesauce-signers-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-sdQe6J1txYV1GVX8/zSGZDyyXuuZomePHSfUDozZmNAnXhCXE0wqVfhLK0yegVMnomSgoeDUCsGmJiTE2BHqoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/secp256k1": "^1.7.1",
|
||||||
|
"applesauce-core": "^5.1.0",
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"nanoid": "^5.0.9",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "lightning",
|
||||||
|
"url": "lightning:nostrudel@geyser.fund"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/applesauce-signers/node_modules/nanoid": {
|
||||||
|
"version": "5.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
|
||||||
|
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || >=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -1326,6 +1454,23 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/didyoumean": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -1426,6 +1571,12 @@
|
|||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -1530,6 +1681,12 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hash-sum": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
@@ -1669,6 +1826,12 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -1717,17 +1880,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nostr-tools": {
|
"node_modules/nostr-tools": {
|
||||||
"version": "2.23.3",
|
"version": "2.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.3.tgz",
|
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz",
|
||||||
"integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==",
|
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "2.1.1",
|
"@noble/ciphers": "^0.5.1",
|
||||||
"@noble/curves": "2.0.1",
|
"@noble/curves": "1.2.0",
|
||||||
"@noble/hashes": "2.0.1",
|
"@noble/hashes": "1.3.1",
|
||||||
"@scure/base": "2.0.0",
|
"@scure/base": "1.1.1",
|
||||||
"@scure/bip32": "2.0.1",
|
"@scure/bip32": "1.3.1",
|
||||||
"@scure/bip39": "2.0.1",
|
"@scure/bip39": "1.2.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
"nostr-wasm": "0.1.0"
|
"nostr-wasm": "0.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -2119,6 +2284,15 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2291,6 +2465,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -4,13 +4,17 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "node server/dev.js",
|
||||||
|
"dev:vite": "vite --host",
|
||||||
|
"dev:server": "node server/server.js",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"start": "node server/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"nostr-tools": "^2.23.3",
|
"applesauce-signers": "^5.1.0",
|
||||||
|
"nostr-tools": "^2.10.4",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<svg width="7813" height="1954" viewBox="0 0 7813 1954" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="7813" height="1954" viewBox="0 0 7813 1954" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M0 0H366.346V1831.88H732.692V1465.5H1099.04V1954H0V0Z" fill="#F2A900"/>
|
<path d="M0 0H366.346V1831.88H732.692V1465.5H1099.04V1954H0V0Z" fill="#FAFAFA"/>
|
||||||
<path d="M1342.79 0H2441.83V1954H1342.79V1221.25H1464.91V1099.13H1587.02V977H1831.25V854.875H2075.48V122.125H1709.14V488.5H1342.79V0ZM1831.25 1099.13H1709.14V1831.88H2075.48V977H1831.25V1099.13Z" fill="#F2A900"/>
|
<path d="M1342.79 0H2441.83V1954H1342.79V1221.25H1464.91V1099.13H1587.02V977H1831.25V854.875H2075.48V122.125H1709.14V488.5H1342.79V0ZM1831.25 1099.13H1709.14V1831.88H2075.48V977H1831.25V1099.13Z" fill="#FAFAFA"/>
|
||||||
<path d="M2685.58 0H3662.51V122.125H3784.62V610.625H3662.51V732.75H3540.39V854.875H3296.16V977H3540.39V1099.13H3662.51V1221.25H3784.62V1954H2685.58V0ZM3051.93 1831.88H3418.28V1099.13H3296.16V977H3051.93V1831.88ZM3051.93 854.875H3296.16V732.75H3418.28V122.125H3051.93V854.875Z" fill="#F2A900"/>
|
<path d="M2685.58 0H3662.51V122.125H3784.62V610.625H3662.51V732.75H3540.39V854.875H3296.16V977H3540.39V1099.13H3662.51V1221.25H3784.62V1954H2685.58V0ZM3051.93 1831.88H3418.28V1099.13H3296.16V977H3051.93V1831.88ZM3051.93 854.875H3296.16V732.75H3418.28V122.125H3051.93V854.875Z" fill="#FAFAFA"/>
|
||||||
<path d="M4028.38 0H4394.72V854.875H4516.84V977H4761.07V0H5127.42V1954H4761.07V1099.13H4516.84V977H4272.61V854.875H4150.49V732.75H4028.38V0Z" fill="#F2A900"/>
|
<path d="M4028.38 0H4394.72V854.875H4516.84V977H4761.07V0H5127.42V1954H4761.07V1099.13H4516.84V977H4272.61V854.875H4150.49V732.75H4028.38V0Z" fill="#FAFAFA"/>
|
||||||
<path d="M5371.17 0H6470.21V732.75H6348.09V854.875H6103.86V977H6348.09V1099.13H6470.21V1954H5371.17V1099.13H5493.28V977H5737.52V854.875H5493.28V732.75H5371.17V0ZM5981.75 977H5859.63V1099.13H5737.52V1831.88H6103.86V1099.13H5981.75V977ZM6103.86 122.125H5737.52V732.75H5859.63V854.875H5981.75V732.75H6103.86V122.125Z" fill="#F2A900"/>
|
<path d="M5371.17 0H6470.21V732.75H6348.09V854.875H6103.86V977H6348.09V1099.13H6470.21V1954H5371.17V1099.13H5493.28V977H5737.52V854.875H5493.28V732.75H5371.17V0ZM5981.75 977H5859.63V1099.13H5737.52V1831.88H6103.86V1099.13H5981.75V977ZM6103.86 122.125H5737.52V732.75H5859.63V854.875H5981.75V732.75H6103.86V122.125Z" fill="#FAFAFA"/>
|
||||||
<path d="M6713.96 0H7080.31V854.875H7202.42V977H7446.65V0H7813V1954H7446.65V1099.13H7202.42V977H6958.19V854.875H6836.08V732.75H6713.96V0Z" fill="#F2A900"/>
|
<path d="M6713.96 0H7080.31V854.875H7202.42V977H7446.65V0H7813V1954H7446.65V1099.13H7202.42V977H6958.19V854.875H6836.08V732.75H6713.96V0Z" fill="#FAFAFA"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
18
public/manifest.webmanifest
Normal file
18
public/manifest.webmanifest
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "L484 Membership",
|
||||||
|
"short_name": "L484",
|
||||||
|
"description": "Private L484 membership card and agreement portal.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/images/small-logo.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
39
public/sw.js
Normal file
39
public/sw.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const CACHE_NAME = 'l484-pwa-v1'
|
||||||
|
const APP_SHELL = [
|
||||||
|
'/',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/images/small-logo.svg',
|
||||||
|
'/images/header-logo.svg',
|
||||||
|
'/images/pattern.jpg',
|
||||||
|
]
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)))
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.clients.claim()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url)
|
||||||
|
if (url.pathname.startsWith('/api/')) return
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((cached) =>
|
||||||
|
cached || fetch(event.request).then((response) => {
|
||||||
|
if (event.request.method === 'GET' && response.ok) {
|
||||||
|
const clone = response.clone()
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
32
server/dev.js
Normal file
32
server/dev.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
|
const processes = [
|
||||||
|
spawn(process.execPath, ['server/server.js'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, PORT: process.env.PORT || '3001' },
|
||||||
|
}),
|
||||||
|
spawn(process.execPath, ['node_modules/vite/bin/vite.js', '--host'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
let isShuttingDown = false
|
||||||
|
|
||||||
|
const shutdown = (code = 0) => {
|
||||||
|
if (isShuttingDown) return
|
||||||
|
isShuttingDown = true
|
||||||
|
for (const child of processes) {
|
||||||
|
if (!child.killed) child.kill('SIGTERM')
|
||||||
|
}
|
||||||
|
process.exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of processes) {
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
if (!isShuttingDown && code !== 0) shutdown(code || 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdown(0))
|
||||||
|
process.on('SIGTERM', () => shutdown(0))
|
||||||
55
server/encryption.js
Normal file
55
server/encryption.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
const configuredKey = process.env.MEMBERSHIP_ENCRYPTION_KEY
|
||||||
|
const fallbackKey = '0000000000000000000000000000000000000000000000000000000000000000'
|
||||||
|
|
||||||
|
if (!configuredKey) {
|
||||||
|
console.warn('MEMBERSHIP_ENCRYPTION_KEY is not set. Using a development fallback key.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getKey = () => {
|
||||||
|
const key = Buffer.from(configuredKey || fallbackKey, 'hex')
|
||||||
|
if (key.length !== 32) {
|
||||||
|
throw new Error('MEMBERSHIP_ENCRYPTION_KEY must be 64 hex characters.')
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptField = (value = '') => {
|
||||||
|
if (!value) return ''
|
||||||
|
const iv = crypto.randomBytes(16)
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv)
|
||||||
|
const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()])
|
||||||
|
const tag = cipher.getAuthTag()
|
||||||
|
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decryptField = (value = '') => {
|
||||||
|
if (!value || !String(value).includes(':')) return value || ''
|
||||||
|
const parts = String(value).split(':')
|
||||||
|
if (parts.length !== 3 || parts[0].length !== 32 || parts[1].length !== 32) return value
|
||||||
|
|
||||||
|
const [ivHex, tagHex, encryptedHex] = parts
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), Buffer.from(ivHex, 'hex'))
|
||||||
|
decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(encryptedHex, 'hex')),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptMembership = (member) => ({
|
||||||
|
...member,
|
||||||
|
fullName: encryptField(member.fullName),
|
||||||
|
email: encryptField(member.email || ''),
|
||||||
|
phone: encryptField(member.phone || ''),
|
||||||
|
signature: encryptField(member.signature || ''),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const decryptMembership = (member) => ({
|
||||||
|
...member,
|
||||||
|
fullName: decryptField(member.fullName),
|
||||||
|
email: decryptField(member.email || ''),
|
||||||
|
phone: decryptField(member.phone || ''),
|
||||||
|
signature: decryptField(member.signature || ''),
|
||||||
|
})
|
||||||
217
server/server.js
Normal file
217
server/server.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import { existsSync, createReadStream } from 'node:fs'
|
||||||
|
import http from 'node:http'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { decryptMembership, encryptMembership } from './encryption.js'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const rootDir = path.resolve(__dirname, '..')
|
||||||
|
const dataDir = path.join(rootDir, 'server', 'data')
|
||||||
|
const membershipsFile = path.join(dataDir, 'memberships.json')
|
||||||
|
const distDir = path.join(rootDir, 'dist')
|
||||||
|
const port = Number(process.env.PORT || 3001)
|
||||||
|
const adminPubkeys = [
|
||||||
|
'7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617',
|
||||||
|
'2be35e9237eaf98fe0a7d1ce3ceb666dccde9c8cc800ff52f138a62c91d90783',
|
||||||
|
]
|
||||||
|
|
||||||
|
let memberships = []
|
||||||
|
|
||||||
|
const json = (res, status, body) => {
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
})
|
||||||
|
res.end(JSON.stringify(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureStore = async () => {
|
||||||
|
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 })
|
||||||
|
if (!existsSync(membershipsFile)) {
|
||||||
|
await fs.writeFile(membershipsFile, '[]', { mode: 0o600 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMemberships = async () => {
|
||||||
|
await ensureStore()
|
||||||
|
memberships = JSON.parse(await fs.readFile(membershipsFile, 'utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMemberships = async () => {
|
||||||
|
await fs.writeFile(membershipsFile, JSON.stringify(memberships, null, 2), { mode: 0o600 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const readBody = async (req) => {
|
||||||
|
const chunks = []
|
||||||
|
let size = 0
|
||||||
|
for await (const chunk of req) {
|
||||||
|
size += chunk.length
|
||||||
|
if (size > 8 * 1024 * 1024) throw new Error('Request body too large')
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanText = (value, max = 160) =>
|
||||||
|
String(value || '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/[\u0000-\u001f\u007f<>`{}[\]\\]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, max)
|
||||||
|
|
||||||
|
const validateMember = (data) => {
|
||||||
|
const member = {
|
||||||
|
membershipId: cleanText(data.membershipId, 32).toUpperCase(),
|
||||||
|
userId: cleanText(data.userId, 80),
|
||||||
|
fullName: cleanText(data.fullName, 80),
|
||||||
|
email: cleanText(data.email, 160).toLowerCase(),
|
||||||
|
phone: cleanText(data.phone, 32),
|
||||||
|
signature: String(data.signature || ''),
|
||||||
|
signedDate: String(data.signedDate || ''),
|
||||||
|
createdAt: String(data.createdAt || new Date().toISOString()),
|
||||||
|
expiresAt: String(data.expiresAt || ''),
|
||||||
|
status: data.status === 'inactive' ? 'inactive' : 'active',
|
||||||
|
npub: cleanText(data.npub, 80),
|
||||||
|
nsecHash: cleanText(data.nsecHash, 64).toLowerCase(),
|
||||||
|
}
|
||||||
|
const errors = []
|
||||||
|
if (!/^L484-\d{4}-[A-Z0-9]{6}$/.test(member.membershipId)) errors.push('Invalid membership ID.')
|
||||||
|
if (member.fullName.length < 2) errors.push('Full name is required.')
|
||||||
|
if (member.email && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(member.email)) errors.push('Invalid email.')
|
||||||
|
if (member.phone && !/^[0-9()+.\-\s]{7,32}$/.test(member.phone)) errors.push('Invalid phone.')
|
||||||
|
if (!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(member.signature)) errors.push('Signature is required.')
|
||||||
|
if (Number.isNaN(new Date(member.signedDate).getTime())) errors.push('Invalid signed date.')
|
||||||
|
if (Number.isNaN(new Date(member.createdAt).getTime())) errors.push('Invalid created date.')
|
||||||
|
if (member.expiresAt && Number.isNaN(new Date(member.expiresAt).getTime())) errors.push('Invalid expiry date.')
|
||||||
|
if (!/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(member.npub)) errors.push('Invalid npub.')
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(member.nsecHash)) errors.push('Invalid nsec hash.')
|
||||||
|
|
||||||
|
if (!member.expiresAt) {
|
||||||
|
const expiresAt = new Date(member.createdAt)
|
||||||
|
expiresAt.setFullYear(expiresAt.getFullYear() + 1)
|
||||||
|
member.expiresAt = expiresAt.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { member, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireAdmin = (req, res) => {
|
||||||
|
const auth = req.headers.authorization || ''
|
||||||
|
const pubkey = auth.startsWith('Bearer ') ? auth.slice(7).trim().toLowerCase() : ''
|
||||||
|
if (!adminPubkeys.includes(pubkey)) {
|
||||||
|
json(res, 403, { error: 'Admin access required.' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const serveStatic = (req, res) => {
|
||||||
|
const requested = decodeURIComponent(new URL(req.url, `http://${req.headers.host}`).pathname)
|
||||||
|
const filePath = requested === '/'
|
||||||
|
? path.join(distDir, 'index.html')
|
||||||
|
: path.join(distDir, requested)
|
||||||
|
const safePath = filePath.startsWith(distDir) && existsSync(filePath) ? filePath : path.join(distDir, 'index.html')
|
||||||
|
const ext = path.extname(safePath)
|
||||||
|
const types = {
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.js': 'text/javascript',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.json': 'application/json',
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' })
|
||||||
|
createReadStream(safePath).pipe(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApi = async (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`)
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/api/health') {
|
||||||
|
return json(res, 200, { ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && url.pathname === '/api/membership/create') {
|
||||||
|
const { member, errors } = validateMember(await readBody(req))
|
||||||
|
if (errors.length) return json(res, 400, { error: 'Validation failed.', errors })
|
||||||
|
|
||||||
|
const existingIndex = memberships.findIndex((item) =>
|
||||||
|
item.membershipId === member.membershipId ||
|
||||||
|
item.userId === member.userId ||
|
||||||
|
item.npub === member.npub ||
|
||||||
|
item.nsecHash === member.nsecHash
|
||||||
|
)
|
||||||
|
const encrypted = encryptMembership(member)
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
memberships[existingIndex] = encrypted
|
||||||
|
} else {
|
||||||
|
memberships.unshift(encrypted)
|
||||||
|
}
|
||||||
|
await saveMemberships()
|
||||||
|
return json(res, existingIndex >= 0 ? 200 : 201, { success: true, membership: member })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/api/membership/check') {
|
||||||
|
const membershipId = cleanText(url.searchParams.get('membershipId'), 32).toUpperCase()
|
||||||
|
const userId = cleanText(url.searchParams.get('userId'), 80)
|
||||||
|
const npub = cleanText(url.searchParams.get('npub'), 80)
|
||||||
|
const found = memberships.find((item) =>
|
||||||
|
(membershipId && item.membershipId === membershipId) ||
|
||||||
|
(userId && item.userId === userId) ||
|
||||||
|
(npub && item.npub === npub)
|
||||||
|
)
|
||||||
|
return json(res, 200, found
|
||||||
|
? { hasMembership: true, membership: decryptMembership(found) }
|
||||||
|
: { hasMembership: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && url.pathname === '/api/membership/recover') {
|
||||||
|
const body = await readBody(req)
|
||||||
|
const nsecHash = cleanText(body.nsecHash, 64).toLowerCase()
|
||||||
|
const found = memberships.find((item) => item.nsecHash === nsecHash)
|
||||||
|
return json(res, found ? 200 : 404, found
|
||||||
|
? { success: true, membership: decryptMembership(found) }
|
||||||
|
: { error: 'No membership found for that nsec.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/api/memberships') {
|
||||||
|
if (!requireAdmin(req, res)) return
|
||||||
|
return json(res, 200, {
|
||||||
|
success: true,
|
||||||
|
memberships: memberships.map(decryptMembership),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE' && url.pathname === '/api/membership') {
|
||||||
|
if (!requireAdmin(req, res)) return
|
||||||
|
const { membershipId } = await readBody(req)
|
||||||
|
const id = cleanText(membershipId, 32).toUpperCase()
|
||||||
|
const before = memberships.length
|
||||||
|
memberships = memberships.filter((item) => item.membershipId !== id)
|
||||||
|
await saveMemberships()
|
||||||
|
return json(res, 200, { success: true, deleted: before - memberships.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
json(res, 404, { error: 'Not found.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadMemberships()
|
||||||
|
|
||||||
|
http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.url.startsWith('/api/')) {
|
||||||
|
await handleApi(req, res)
|
||||||
|
} else {
|
||||||
|
serveStatic(req, res)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
json(res, 500, { error: error.message || 'Server error.' })
|
||||||
|
}
|
||||||
|
}).listen(port, '127.0.0.1', () => {
|
||||||
|
console.log(`L484 server listening on http://127.0.0.1:${port}`)
|
||||||
|
})
|
||||||
408
src/App.vue
408
src/App.vue
@@ -1,6 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { getPublicKey, nip19 } from 'nostr-tools'
|
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
|
||||||
|
import {
|
||||||
|
cancelPendingRemoteAppLogin,
|
||||||
|
clearSigner,
|
||||||
|
hasPendingRemoteAppLogin,
|
||||||
|
loginWithExtension,
|
||||||
|
loginWithRemoteApp,
|
||||||
|
resumeRemoteAppLogin,
|
||||||
|
} from './services/signer'
|
||||||
|
|
||||||
const heroBackgrounds = Object.entries(
|
const heroBackgrounds = Object.entries(
|
||||||
import.meta.glob('../public/images/bg-*.{avif,webp,jpg,jpeg,png}', {
|
import.meta.glob('../public/images/bg-*.{avif,webp,jpg,jpeg,png}', {
|
||||||
@@ -15,6 +23,9 @@ const heroBackgrounds = Object.entries(
|
|||||||
const MEMBERS_KEY = 'l484-members'
|
const MEMBERS_KEY = 'l484-members'
|
||||||
const CURRENT_MEMBER_KEY = 'l484-current-member'
|
const CURRENT_MEMBER_KEY = 'l484-current-member'
|
||||||
const ADMIN_AUTH_KEY = 'l484-admin-user'
|
const ADMIN_AUTH_KEY = 'l484-admin-user'
|
||||||
|
const USER_ID_KEY = 'l484-user-id'
|
||||||
|
const MEMBER_KEYS_KEY = 'l484-member-keys'
|
||||||
|
const SIGNER_LOGIN_COMPLETE_KEY = 'l484-signer-login-complete'
|
||||||
const adminPubkeys = [
|
const adminPubkeys = [
|
||||||
'7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617',
|
'7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617',
|
||||||
'2be35e9237eaf98fe0a7d1ce3ceb666dccde9c8cc800ff52f138a62c91d90783',
|
'2be35e9237eaf98fe0a7d1ce3ceb666dccde9c8cc800ff52f138a62c91d90783',
|
||||||
@@ -43,11 +54,17 @@ const covenantItems = [
|
|||||||
const activeBackground = ref(0)
|
const activeBackground = ref(0)
|
||||||
const hasRotatingBackgrounds = computed(() => heroBackgrounds.length > 1)
|
const hasRotatingBackgrounds = computed(() => heroBackgrounds.length > 1)
|
||||||
const isSignupOpen = ref(false)
|
const isSignupOpen = ref(false)
|
||||||
|
const isMemberSigninOpen = ref(false)
|
||||||
const signupStep = ref(0)
|
const signupStep = ref(0)
|
||||||
const members = ref([])
|
const members = ref([])
|
||||||
const currentMemberId = ref('')
|
const currentMemberId = ref('')
|
||||||
const createdMember = ref(null)
|
const createdMember = ref(null)
|
||||||
const isCardRevealing = ref(false)
|
const isCardRevealing = ref(false)
|
||||||
|
const generatedCredentials = ref(null)
|
||||||
|
const copiedKey = ref('')
|
||||||
|
const memberSigninError = ref('')
|
||||||
|
const isMemberSigninLoading = ref(false)
|
||||||
|
const isRemoteSignerLoading = ref(false)
|
||||||
const formError = ref('')
|
const formError = ref('')
|
||||||
const signatureCanvas = ref(null)
|
const signatureCanvas = ref(null)
|
||||||
const signatureHasInk = ref(false)
|
const signatureHasInk = ref(false)
|
||||||
@@ -115,6 +132,62 @@ const sanitizeText = (value, maxLength) =>
|
|||||||
.trim()
|
.trim()
|
||||||
.slice(0, maxLength)
|
.slice(0, maxLength)
|
||||||
|
|
||||||
|
const getUserId = () => {
|
||||||
|
let userId = localStorage.getItem(USER_ID_KEY)
|
||||||
|
if (!userId) {
|
||||||
|
userId = `l484-user-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
localStorage.setItem(USER_ID_KEY, userId)
|
||||||
|
}
|
||||||
|
return userId
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256Hex = async (value) => {
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
|
||||||
|
return bytesToHex(new Uint8Array(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminHeaders = () => (adminUser.value ? { Authorization: `Bearer ${adminUser.value}` } : {})
|
||||||
|
|
||||||
|
const fetchJson = async (url, options = {}) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || data.message || `Request failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadStoredMemberKeys = () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(MEMBER_KEYS_KEY) || 'null')
|
||||||
|
if (parsed?.nsec?.startsWith('nsec1') && parsed?.npub?.startsWith('npub1')) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed local key cache.
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveStoredMemberKeys = (keys) => {
|
||||||
|
if (!keys?.nsec?.startsWith('nsec1') || !keys?.npub?.startsWith('npub1')) return
|
||||||
|
localStorage.setItem(MEMBER_KEYS_KEY, JSON.stringify(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async (value, label) => {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
copiedKey.value = label
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (copiedKey.value === label) copiedKey.value = ''
|
||||||
|
}, 1600)
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizeForm = () => {
|
const sanitizeForm = () => {
|
||||||
form.fullName = sanitizeText(form.fullName, MAX_NAME_LENGTH)
|
form.fullName = sanitizeText(form.fullName, MAX_NAME_LENGTH)
|
||||||
form.email = sanitizeText(form.email, MAX_EMAIL_LENGTH).toLowerCase()
|
form.email = sanitizeText(form.email, MAX_EMAIL_LENGTH).toLowerCase()
|
||||||
@@ -160,6 +233,9 @@ const normalizeMember = (value) => {
|
|||||||
const signedDate = String(value.signedDate || '')
|
const signedDate = String(value.signedDate || '')
|
||||||
const createdAt = String(value.createdAt || '')
|
const createdAt = String(value.createdAt || '')
|
||||||
const expiresAt = String(value.expiresAt || '')
|
const expiresAt = String(value.expiresAt || '')
|
||||||
|
const userId = sanitizeText(value.userId, 80)
|
||||||
|
const npub = sanitizeText(value.npub, 80)
|
||||||
|
const nsecHash = sanitizeText(value.nsecHash, 64).toLowerCase()
|
||||||
|
|
||||||
if (!MEMBERSHIP_ID_PATTERN.test(membershipId)) return null
|
if (!MEMBERSHIP_ID_PATTERN.test(membershipId)) return null
|
||||||
if (fullName.length < 2) return null
|
if (fullName.length < 2) return null
|
||||||
@@ -173,6 +249,9 @@ const normalizeMember = (value) => {
|
|||||||
fullName,
|
fullName,
|
||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
|
userId,
|
||||||
|
npub,
|
||||||
|
nsecHash,
|
||||||
signature,
|
signature,
|
||||||
signedDate,
|
signedDate,
|
||||||
createdAt,
|
createdAt,
|
||||||
@@ -181,7 +260,7 @@ const normalizeMember = (value) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMembers = () => {
|
const loadMembers = async () => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(localStorage.getItem(MEMBERS_KEY) || '[]')
|
const parsed = JSON.parse(localStorage.getItem(MEMBERS_KEY) || '[]')
|
||||||
members.value = Array.isArray(parsed) ? parsed.map(normalizeMember).filter(Boolean) : []
|
members.value = Array.isArray(parsed) ? parsed.map(normalizeMember).filter(Boolean) : []
|
||||||
@@ -191,6 +270,31 @@ const loadMembers = () => {
|
|||||||
members.value = []
|
members.value = []
|
||||||
currentMemberId.value = ''
|
currentMemberId.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isAdminAuthenticated.value) {
|
||||||
|
const data = await fetchJson('/api/memberships', { headers: adminHeaders() })
|
||||||
|
members.value = Array.isArray(data.memberships) ? data.memberships.map(normalizeMember).filter(Boolean) : []
|
||||||
|
saveMembers()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = localStorage.getItem(USER_ID_KEY)
|
||||||
|
if (userId) {
|
||||||
|
const data = await fetchJson(`/api/membership/check?userId=${encodeURIComponent(userId)}`)
|
||||||
|
if (data.hasMembership && data.membership) {
|
||||||
|
const member = normalizeMember(data.membership)
|
||||||
|
if (member) {
|
||||||
|
members.value = [member, ...members.value.filter((item) => item.membershipId !== member.membershipId)]
|
||||||
|
currentMemberId.value = member.membershipId
|
||||||
|
localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
|
||||||
|
saveMembers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not sync memberships with server:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAdminSession = () => {
|
const loadAdminSession = () => {
|
||||||
@@ -211,9 +315,15 @@ const openSignup = () => {
|
|||||||
signupStep.value = currentMember.value ? 4 : 0
|
signupStep.value = currentMember.value ? 4 : 0
|
||||||
createdMember.value = currentMember.value
|
createdMember.value = currentMember.value
|
||||||
isCardRevealing.value = false
|
isCardRevealing.value = false
|
||||||
|
generatedCredentials.value = null
|
||||||
formError.value = ''
|
formError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openMemberSignin = () => {
|
||||||
|
isMemberSigninOpen.value = true
|
||||||
|
memberSigninError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
const navigateTo = (path) => {
|
const navigateTo = (path) => {
|
||||||
window.history.pushState({}, '', path)
|
window.history.pushState({}, '', path)
|
||||||
currentPath.value = window.location.pathname
|
currentPath.value = window.location.pathname
|
||||||
@@ -222,6 +332,28 @@ const navigateTo = (path) => {
|
|||||||
const closeSignup = () => {
|
const closeSignup = () => {
|
||||||
isSignupOpen.value = false
|
isSignupOpen.value = false
|
||||||
isCardRevealing.value = false
|
isCardRevealing.value = false
|
||||||
|
generatedCredentials.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMemberSignin = () => {
|
||||||
|
isMemberSigninOpen.value = false
|
||||||
|
memberSigninError.value = ''
|
||||||
|
isRemoteSignerLoading.value = false
|
||||||
|
cancelPendingRemoteAppLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
const signOutMember = () => {
|
||||||
|
clearSigner()
|
||||||
|
cancelPendingRemoteAppLogin()
|
||||||
|
currentMemberId.value = ''
|
||||||
|
createdMember.value = null
|
||||||
|
generatedCredentials.value = null
|
||||||
|
isSignupOpen.value = false
|
||||||
|
isMemberSigninOpen.value = false
|
||||||
|
isRemoteSignerLoading.value = false
|
||||||
|
isMemberSigninLoading.value = false
|
||||||
|
memberSigninError.value = ''
|
||||||
|
localStorage.removeItem(CURRENT_MEMBER_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -254,7 +386,86 @@ const previousStep = () => {
|
|||||||
signupStep.value = Math.max(0, signupStep.value - 1)
|
signupStep.value = Math.max(0, signupStep.value - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createMembership = () => {
|
const completeMemberSignerLogin = async (pubkey) => {
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
const data = await fetchJson(`/api/membership/check?npub=${encodeURIComponent(npub)}`)
|
||||||
|
const member = normalizeMember(data.membership)
|
||||||
|
if (!data.hasMembership || !member) {
|
||||||
|
throw new Error(`Signer connected as ${npub.slice(0, 12)}...${npub.slice(-8)}, but no L484 membership is attached to that npub. Import the nsec issued at signup into this signer, or import your encrypted member file.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
members.value = [member, ...members.value.filter((item) => item.membershipId !== member.membershipId)]
|
||||||
|
currentMemberId.value = member.membershipId
|
||||||
|
createdMember.value = member
|
||||||
|
localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
|
||||||
|
if (member.userId) localStorage.setItem(USER_ID_KEY, member.userId)
|
||||||
|
saveMembers()
|
||||||
|
localStorage.setItem(SIGNER_LOGIN_COMPLETE_KEY, JSON.stringify({
|
||||||
|
membershipId: member.membershipId,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
}))
|
||||||
|
closeMemberSignin()
|
||||||
|
openSignup()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginMemberWithExtension = async () => {
|
||||||
|
memberSigninError.value = ''
|
||||||
|
isMemberSigninLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completeMemberSignerLogin(await loginWithExtension())
|
||||||
|
} catch (error) {
|
||||||
|
memberSigninError.value = error instanceof Error ? error.message : 'Sign in failed.'
|
||||||
|
} finally {
|
||||||
|
isMemberSigninLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginMemberWithRemoteApp = async () => {
|
||||||
|
memberSigninError.value = ''
|
||||||
|
isRemoteSignerLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completeMemberSignerLogin(await loginWithRemoteApp())
|
||||||
|
} catch (error) {
|
||||||
|
memberSigninError.value = error instanceof Error ? error.message : 'Remote signer login failed.'
|
||||||
|
} finally {
|
||||||
|
isRemoteSignerLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumePendingRemoteSignin = async () => {
|
||||||
|
if (!hasPendingRemoteAppLogin() || isRemoteSignerLoading.value) return
|
||||||
|
isMemberSigninOpen.value = true
|
||||||
|
memberSigninError.value = ''
|
||||||
|
isRemoteSignerLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completeMemberSignerLogin(await resumeRemoteAppLogin())
|
||||||
|
} catch (error) {
|
||||||
|
memberSigninError.value = error instanceof Error ? error.message : 'Remote signer login failed.'
|
||||||
|
} finally {
|
||||||
|
isRemoteSignerLoading.value = false
|
||||||
|
if (window.location.pathname === '/auth/nostr-callback') {
|
||||||
|
window.history.replaceState({}, '', '/')
|
||||||
|
currentPath.value = '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignerCompletion = async () => {
|
||||||
|
await loadMembers()
|
||||||
|
if (!currentMember.value) return
|
||||||
|
isRemoteSignerLoading.value = false
|
||||||
|
isMemberSigninLoading.value = false
|
||||||
|
isMemberSigninOpen.value = false
|
||||||
|
memberSigninError.value = ''
|
||||||
|
createdMember.value = currentMember.value
|
||||||
|
signupStep.value = 4
|
||||||
|
isSignupOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMembership = async () => {
|
||||||
sanitizeForm()
|
sanitizeForm()
|
||||||
if (!validateApplicant()) {
|
if (!validateApplicant()) {
|
||||||
return
|
return
|
||||||
@@ -268,12 +479,19 @@ const createMembership = () => {
|
|||||||
const createdAt = new Date()
|
const createdAt = new Date()
|
||||||
const expiresAt = new Date(createdAt)
|
const expiresAt = new Date(createdAt)
|
||||||
expiresAt.setFullYear(expiresAt.getFullYear() + 1)
|
expiresAt.setFullYear(expiresAt.getFullYear() + 1)
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const pubkey = getPublicKey(privateKey)
|
||||||
|
const nsec = nip19.nsecEncode(privateKey)
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
|
||||||
const member = {
|
const member = {
|
||||||
membershipId: `L484-${createdAt.getFullYear()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`,
|
membershipId: `L484-${createdAt.getFullYear()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`,
|
||||||
|
userId: getUserId(),
|
||||||
fullName: form.fullName.trim(),
|
fullName: form.fullName.trim(),
|
||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
|
npub,
|
||||||
|
nsecHash: await sha256Hex(nsec),
|
||||||
signature: form.signature.trim(),
|
signature: form.signature.trim(),
|
||||||
signedDate: createdAt.toISOString(),
|
signedDate: createdAt.toISOString(),
|
||||||
createdAt: createdAt.toISOString(),
|
createdAt: createdAt.toISOString(),
|
||||||
@@ -281,10 +499,19 @@ const createMembership = () => {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
|
|
||||||
members.value = [member, ...members.value]
|
try {
|
||||||
currentMemberId.value = member.membershipId
|
const result = await fetchJson('/api/membership/create', {
|
||||||
createdMember.value = member
|
method: 'POST',
|
||||||
localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
|
body: JSON.stringify(member),
|
||||||
|
})
|
||||||
|
const savedMember = normalizeMember(result.membership) || member
|
||||||
|
members.value = [savedMember, ...members.value.filter((item) => item.membershipId !== savedMember.membershipId)]
|
||||||
|
currentMemberId.value = savedMember.membershipId
|
||||||
|
createdMember.value = savedMember
|
||||||
|
generatedCredentials.value = { nsec, npub }
|
||||||
|
saveStoredMemberKeys(generatedCredentials.value)
|
||||||
|
localStorage.setItem(CURRENT_MEMBER_KEY, savedMember.membershipId)
|
||||||
|
localStorage.setItem(USER_ID_KEY, savedMember.userId)
|
||||||
saveMembers()
|
saveMembers()
|
||||||
resetForm()
|
resetForm()
|
||||||
isCardRevealing.value = true
|
isCardRevealing.value = true
|
||||||
@@ -293,6 +520,9 @@ const createMembership = () => {
|
|||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
isCardRevealing.value = false
|
isCardRevealing.value = false
|
||||||
}, 2400)
|
}, 2400)
|
||||||
|
} catch (error) {
|
||||||
|
formError.value = error instanceof Error ? error.message : 'Could not save membership.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncSignatureCanvas = () => {
|
const syncSignatureCanvas = () => {
|
||||||
@@ -352,7 +582,18 @@ const clearSignature = () => {
|
|||||||
syncSignatureCanvas()
|
syncSignatureCanvas()
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteMember = (membershipId) => {
|
const deleteMember = async (membershipId) => {
|
||||||
|
if (isAdminAuthenticated.value) {
|
||||||
|
try {
|
||||||
|
await fetchJson('/api/membership', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: adminHeaders(),
|
||||||
|
body: JSON.stringify({ membershipId }),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not delete membership from server:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
members.value = members.value.filter((member) => member.membershipId !== membershipId)
|
members.value = members.value.filter((member) => member.membershipId !== membershipId)
|
||||||
if (currentMemberId.value === membershipId) {
|
if (currentMemberId.value === membershipId) {
|
||||||
currentMemberId.value = ''
|
currentMemberId.value = ''
|
||||||
@@ -399,6 +640,7 @@ const loginWithNip07 = async () => {
|
|||||||
}
|
}
|
||||||
const pubkey = await window.nostr.getPublicKey()
|
const pubkey = await window.nostr.getPublicKey()
|
||||||
setAdminSession(pubkey)
|
setAdminSession(pubkey)
|
||||||
|
await loadMembers()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
adminError.value = error instanceof Error ? error.message : 'Nostr extension login failed.'
|
adminError.value = error instanceof Error ? error.message : 'Nostr extension login failed.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -425,6 +667,7 @@ const loginWithNsec = async () => {
|
|||||||
const privateKey = decoded.data
|
const privateKey = decoded.data
|
||||||
const pubkey = getPublicKey(privateKey instanceof Uint8Array ? privateKey : bytesToHex(privateKey))
|
const pubkey = getPublicKey(privateKey instanceof Uint8Array ? privateKey : bytesToHex(privateKey))
|
||||||
setAdminSession(pubkey)
|
setAdminSession(pubkey)
|
||||||
|
await loadMembers()
|
||||||
adminNsec.value = ''
|
adminNsec.value = ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
adminError.value = error instanceof Error ? error.message : 'Nsec login failed.'
|
adminError.value = error instanceof Error ? error.message : 'Nsec login failed.'
|
||||||
@@ -486,12 +729,12 @@ const downloadEncryptedBackup = async () => {
|
|||||||
backupMessage.value = ''
|
backupMessage.value = ''
|
||||||
|
|
||||||
if (!currentMember.value) {
|
if (!currentMember.value) {
|
||||||
backupError.value = 'Create a membership card before downloading a backup.'
|
backupError.value = 'Create a membership card before exporting.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backupPassword.value.length < 8) {
|
if (backupPassword.value.length < 8) {
|
||||||
backupError.value = 'Use at least 8 characters for the backup password.'
|
backupError.value = 'Use at least 8 characters for the export password.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,9 +744,10 @@ const downloadEncryptedBackup = async () => {
|
|||||||
const key = await deriveBackupKey(backupPassword.value, salt)
|
const key = await deriveBackupKey(backupPassword.value, salt)
|
||||||
const payload = {
|
const payload = {
|
||||||
type: 'l484-membership-card-backup',
|
type: 'l484-membership-card-backup',
|
||||||
version: 1,
|
version: 2,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
member: currentMember.value,
|
member: currentMember.value,
|
||||||
|
keys: generatedCredentials.value || loadStoredMemberKeys(),
|
||||||
}
|
}
|
||||||
const encrypted = await crypto.subtle.encrypt(
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
{ name: 'AES-GCM', iv },
|
{ name: 'AES-GCM', iv },
|
||||||
@@ -523,13 +767,13 @@ const downloadEncryptedBackup = async () => {
|
|||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const anchor = document.createElement('a')
|
const anchor = document.createElement('a')
|
||||||
anchor.href = url
|
anchor.href = url
|
||||||
anchor.download = `${currentMember.value.membershipId}-encrypted-card-backup.json`
|
anchor.download = `${currentMember.value.membershipId}-encrypted-member-export.json`
|
||||||
anchor.click()
|
anchor.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
backupMessage.value = 'Encrypted card backup created.'
|
backupMessage.value = 'Encrypted member export created.'
|
||||||
isBackupOpen.value = false
|
isBackupOpen.value = false
|
||||||
} catch {
|
} catch {
|
||||||
backupError.value = 'Could not create the encrypted backup.'
|
backupError.value = 'Could not create the encrypted export.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,14 +785,14 @@ const restoreEncryptedBackup = async (event) => {
|
|||||||
|
|
||||||
if (!file) return
|
if (!file) return
|
||||||
if (restorePassword.value.length < 1) {
|
if (restorePassword.value.length < 1) {
|
||||||
backupError.value = 'Enter the backup password before choosing a file.'
|
backupError.value = 'Enter the export password before choosing a file.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backup = JSON.parse(await file.text())
|
const backup = JSON.parse(await file.text())
|
||||||
if (backup.type !== 'l484-membership-card-backup' || !backup.data) {
|
if (backup.type !== 'l484-membership-card-backup' || !backup.data) {
|
||||||
throw new Error('Invalid backup file')
|
throw new Error('Invalid encrypted member file')
|
||||||
}
|
}
|
||||||
|
|
||||||
const salt = decodeBase64(backup.salt)
|
const salt = decodeBase64(backup.salt)
|
||||||
@@ -565,12 +809,17 @@ const restoreEncryptedBackup = async (event) => {
|
|||||||
members.value = [member, ...members.value.filter((item) => item.membershipId !== member.membershipId)]
|
members.value = [member, ...members.value.filter((item) => item.membershipId !== member.membershipId)]
|
||||||
currentMemberId.value = member.membershipId
|
currentMemberId.value = member.membershipId
|
||||||
createdMember.value = member
|
createdMember.value = member
|
||||||
|
if (payload.keys?.nsec?.startsWith('nsec1') && payload.keys?.npub?.startsWith('npub1')) {
|
||||||
|
generatedCredentials.value = payload.keys
|
||||||
|
saveStoredMemberKeys(payload.keys)
|
||||||
|
}
|
||||||
localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
|
localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
|
||||||
|
if (member.userId) localStorage.setItem(USER_ID_KEY, member.userId)
|
||||||
saveMembers()
|
saveMembers()
|
||||||
backupMessage.value = 'Encrypted card backup restored.'
|
backupMessage.value = 'Encrypted member file imported.'
|
||||||
isRestoreOpen.value = false
|
isRestoreOpen.value = false
|
||||||
} catch {
|
} catch {
|
||||||
backupError.value = 'Could not restore this backup. Check the password and file.'
|
backupError.value = 'Could not import this file. Check the password and file.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,11 +975,28 @@ const formatDate = (dateString) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMembers()
|
|
||||||
loadAdminSession()
|
loadAdminSession()
|
||||||
|
loadMembers()
|
||||||
|
const navigationEntry = performance.getEntriesByType?.('navigation')?.[0]
|
||||||
|
const isPageReload = navigationEntry?.type === 'reload'
|
||||||
|
if (isPageReload) {
|
||||||
|
cancelPendingRemoteAppLogin()
|
||||||
|
isRemoteSignerLoading.value = false
|
||||||
|
if (window.location.pathname === '/auth/nostr-callback') {
|
||||||
|
window.history.replaceState({}, '', '/')
|
||||||
|
currentPath.value = '/'
|
||||||
|
}
|
||||||
|
} else if (window.location.pathname === '/auth/nostr-callback') {
|
||||||
|
resumePendingRemoteSignin()
|
||||||
|
}
|
||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('popstate', () => {
|
||||||
currentPath.value = window.location.pathname
|
currentPath.value = window.location.pathname
|
||||||
})
|
})
|
||||||
|
window.addEventListener('storage', (event) => {
|
||||||
|
if (event.key === SIGNER_LOGIN_COMPLETE_KEY || event.key === MEMBERS_KEY || event.key === CURRENT_MEMBER_KEY) {
|
||||||
|
handleSignerCompletion()
|
||||||
|
}
|
||||||
|
})
|
||||||
window.addEventListener('resize', syncSignatureCanvas)
|
window.addEventListener('resize', syncSignatureCanvas)
|
||||||
if (!hasRotatingBackgrounds.value) return
|
if (!hasRotatingBackgrounds.value) return
|
||||||
|
|
||||||
@@ -771,8 +1037,21 @@ watch(signupStep, async (step) => {
|
|||||||
|
|
||||||
<div class="relative z-10 mx-auto flex min-h-svh w-full max-w-7xl flex-col px-4 py-5 sm:px-10 sm:py-7 lg:px-12">
|
<div class="relative z-10 mx-auto flex min-h-svh w-full max-w-7xl flex-col px-4 py-5 sm:px-10 sm:py-7 lg:px-12">
|
||||||
<header class="intro-header flex items-center justify-between gap-4">
|
<header class="intro-header flex items-center justify-between gap-4">
|
||||||
<img class="h-4 w-auto sm:h-5" src="/images/header-logo.svg" alt="L484" />
|
<span class="animated-header-logo" aria-label="L484">
|
||||||
|
<img class="h-4 w-auto sm:h-5" src="/images/header-logo.svg" alt="" aria-hidden="true" />
|
||||||
|
<svg class="header-logo-outline" viewBox="0 0 7813 1954" aria-hidden="true">
|
||||||
|
<path d="M0 0H366.346V1831.88H732.692V1465.5H1099.04V1954H0V0Z" />
|
||||||
|
<path d="M1342.79 0H2441.83V1954H1342.79V1221.25H1464.91V1099.13H1587.02V977H1831.25V854.875H2075.48V122.125H1709.14V488.5H1342.79V0ZM1831.25 1099.13H1709.14V1831.88H2075.48V977H1831.25V1099.13Z" />
|
||||||
|
<path d="M2685.58 0H3662.51V122.125H3784.62V610.625H3662.51V732.75H3540.39V854.875H3296.16V977H3540.39V1099.13H3662.51V1221.25H3784.62V1954H2685.58V0ZM3051.93 1831.88H3418.28V1099.13H3296.16V977H3051.93V1831.88ZM3051.93 854.875H3296.16V732.75H3418.28V122.125H3051.93V854.875Z" />
|
||||||
|
<path d="M4028.38 0H4394.72V854.875H4516.84V977H4761.07V0H5127.42V1954H4761.07V1099.13H4516.84V977H4272.61V854.875H4150.49V732.75H4028.38V0Z" />
|
||||||
|
<path d="M5371.17 0H6470.21V732.75H6348.09V854.875H6103.86V977H6348.09V1099.13H6470.21V1954H5371.17V1099.13H5493.28V977H5737.52V854.875H5493.28V732.75H5371.17V0ZM5981.75 977H5859.63V1099.13H5737.52V1831.88H6103.86V1099.13H5981.75V977ZM6103.86 122.125H5737.52V732.75H5859.63V854.875H5981.75V732.75H6103.86V122.125Z" />
|
||||||
|
<path d="M6713.96 0H7080.31V854.875H7202.42V977H7446.65V0H7813V1954H7446.65V1099.13H7202.42V977H6958.19V854.875H6836.08V732.75H6713.96V0Z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="member-button ghost-member-button" type="button" @click="currentMember ? signOutMember() : openMemberSignin()">
|
||||||
|
{{ currentMember ? 'Sign out' : 'Sign in' }}
|
||||||
|
</button>
|
||||||
<button class="member-button" type="button" @click="openSignup">
|
<button class="member-button" type="button" @click="openSignup">
|
||||||
{{ currentMember ? 'View card' : 'Become a member' }}
|
{{ currentMember ? 'View card' : 'Become a member' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -782,8 +1061,8 @@ watch(signupStep, async (step) => {
|
|||||||
<div class="hero-content grid flex-1 items-center gap-5 py-6 sm:gap-10 sm:py-14 lg:py-16">
|
<div class="hero-content grid flex-1 items-center gap-5 py-6 sm:gap-10 sm:py-14 lg:py-16">
|
||||||
<div class="intro-copy">
|
<div class="intro-copy">
|
||||||
<h1 class="hero-title font-black uppercase leading-[0.86] tracking-normal">
|
<h1 class="hero-title font-black uppercase leading-[0.86] tracking-normal">
|
||||||
<span class="hero-title-line">Decentralization</span>
|
<span class="hero-title-line hero-title-line-primary">Decentralization</span>
|
||||||
<span class="hero-title-line">in motion.</span>
|
<span class="hero-title-line hero-title-line-secondary">In Motion</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -861,7 +1140,7 @@ watch(signupStep, async (step) => {
|
|||||||
<p class="section-kicker">Admin Panel</p>
|
<p class="section-kicker">Admin Panel</p>
|
||||||
<h2 class="section-title">Members</h2>
|
<h2 class="section-title">Members</h2>
|
||||||
<p class="section-copy">
|
<p class="section-copy">
|
||||||
Local admin view for membership cards created or restored in this browser.
|
Local admin view for membership cards created or imported in this browser.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -932,6 +1211,34 @@ watch(signupStep, async (step) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div v-if="isMemberSigninOpen" class="modal-backdrop" @click.self="closeMemberSignin">
|
||||||
|
<div class="backup-modal">
|
||||||
|
<div class="flex items-center justify-between gap-4 border-b border-white/10 p-5">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">Member Sign In</p>
|
||||||
|
<h2 class="text-2xl font-black uppercase leading-none">Nostr signer</h2>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" type="button" aria-label="Close" @click="closeMemberSignin"></button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4 p-5">
|
||||||
|
<p class="text-sm leading-6 text-white/62">
|
||||||
|
Sign in with the same npub you received at signup. Your browser extension or Amber must hold that issued key.
|
||||||
|
</p>
|
||||||
|
<div class="signin-options">
|
||||||
|
<button class="primary-action signin-option" type="button" :disabled="isMemberSigninLoading || isRemoteSignerLoading" @click="loginMemberWithExtension">
|
||||||
|
{{ isMemberSigninLoading ? 'Connecting...' : 'Browser extension' }}
|
||||||
|
<small>NIP-07 · Alby, nos2x, Primal</small>
|
||||||
|
</button>
|
||||||
|
<button class="primary-action signin-option" type="button" :disabled="isMemberSigninLoading || isRemoteSignerLoading" @click="loginMemberWithRemoteApp">
|
||||||
|
{{ isRemoteSignerLoading ? 'Waiting for signer...' : 'Open signer app' }}
|
||||||
|
<small>Amber, Primal, or Nostr Connect</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="memberSigninError" class="validation-message text-sm text-red-200">{{ memberSigninError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="isAgreementOpen && selectedAgreementMember" class="modal-backdrop" @click.self="closeAgreement">
|
<div v-if="isAgreementOpen && selectedAgreementMember" class="modal-backdrop" @click.self="closeAgreement">
|
||||||
<div class="agreement-modal">
|
<div class="agreement-modal">
|
||||||
<div class="flex items-center justify-between gap-4 border-b border-white/10 p-5">
|
<div class="flex items-center justify-between gap-4 border-b border-white/10 p-5">
|
||||||
@@ -1011,7 +1318,7 @@ watch(signupStep, async (step) => {
|
|||||||
generated membership card.
|
generated membership card.
|
||||||
</p>
|
</p>
|
||||||
<div class="info-panel">
|
<div class="info-panel">
|
||||||
<p>Membership includes a locally stored card, encrypted backup, and a signed acknowledgement.</p>
|
<p>Membership includes a locally stored card, encrypted export file, and a signed acknowledgement.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1060,9 +1367,9 @@ watch(signupStep, async (step) => {
|
|||||||
<li v-for="item in covenantItems" :key="item">{{ item }}</li>
|
<li v-for="item in covenantItems" :key="item">{{ item }}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-start gap-3 text-sm text-white/75">
|
<label class="flex items-center gap-3 text-sm leading-none text-white/75">
|
||||||
<input v-model="form.accepted" class="mt-1 h-4 w-4 accent-amber-400" type="checkbox" />
|
<input v-model="form.accepted" class="h-4 w-4 shrink-0 accent-amber-400" type="checkbox" />
|
||||||
I have read and agree to the L484 Membership Covenant.
|
<span>I have read and agree to the L484 Membership Covenant.</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1127,8 +1434,29 @@ watch(signupStep, async (step) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-note">
|
<p class="card-note">
|
||||||
Your card is saved locally in this browser. Keep an encrypted backup to recover it later.
|
Your card is saved locally in this browser. Keep an encrypted export file to import it later.
|
||||||
</p>
|
</p>
|
||||||
|
<div v-if="generatedCredentials" class="member-keys">
|
||||||
|
<p class="field-label">Member keys</p>
|
||||||
|
<p class="text-sm leading-6 text-white/62">
|
||||||
|
Save this nsec. It lets you recover your card and member information. Back it up with
|
||||||
|
<a href="https://keys.band" target="_blank" rel="noreferrer">keys.band</a>, import it into a Nostr browser extension, or use Amber on mobile to sign in later.
|
||||||
|
</p>
|
||||||
|
<div class="member-key-row">
|
||||||
|
<span>npub</span>
|
||||||
|
<code>{{ generatedCredentials.npub }}</code>
|
||||||
|
<button class="secondary-action compact-action" type="button" @click="copyToClipboard(generatedCredentials.npub, 'npub')">
|
||||||
|
{{ copiedKey === 'npub' ? '✓' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="member-key-row">
|
||||||
|
<span>nsec</span>
|
||||||
|
<code>{{ generatedCredentials.nsec }}</code>
|
||||||
|
<button class="secondary-action compact-action" type="button" @click="copyToClipboard(generatedCredentials.nsec, 'nsec')">
|
||||||
|
{{ copiedKey === 'nsec' ? '✓' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="formError" class="validation-message rounded border border-red-400/40 bg-red-500/10 p-3 text-sm text-red-200">
|
<p v-if="formError" class="validation-message rounded border border-red-400/40 bg-red-500/10 p-3 text-sm text-red-200">
|
||||||
@@ -1144,10 +1472,10 @@ watch(signupStep, async (step) => {
|
|||||||
<div class="modal-footer-actions">
|
<div class="modal-footer-actions">
|
||||||
<template v-if="signupStep === 4">
|
<template v-if="signupStep === 4">
|
||||||
<button class="secondary-action" type="button" @click="openBackup">
|
<button class="secondary-action" type="button" @click="openBackup">
|
||||||
Backup
|
Export
|
||||||
</button>
|
</button>
|
||||||
<button class="secondary-action" type="button" @click="openRestore">
|
<button class="secondary-action" type="button" @click="openRestore">
|
||||||
Restore
|
Import
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<button v-if="signupStep === 0" class="primary-action" type="button" @click="nextStep">
|
<button v-if="signupStep === 0" class="primary-action" type="button" @click="nextStep">
|
||||||
@@ -1167,16 +1495,16 @@ watch(signupStep, async (step) => {
|
|||||||
<div v-if="isBackupOpen" class="modal-backdrop" @click.self="isBackupOpen = false">
|
<div v-if="isBackupOpen" class="modal-backdrop" @click.self="isBackupOpen = false">
|
||||||
<div class="backup-modal">
|
<div class="backup-modal">
|
||||||
<div class="border-b border-white/10 p-5">
|
<div class="border-b border-white/10 p-5">
|
||||||
<p class="section-kicker">Encrypted Backup</p>
|
<p class="section-kicker">Encrypted Export</p>
|
||||||
<h2 class="text-2xl font-black uppercase leading-none">Protect card backup</h2>
|
<h2 class="text-2xl font-black uppercase leading-none">Export member file</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 p-5">
|
<div class="space-y-4 p-5">
|
||||||
<p class="text-sm leading-6 text-white/62">
|
<p class="text-sm leading-6 text-white/62">
|
||||||
Choose a password for the encrypted JSON backup. You will need this password to restore
|
Choose a password for the encrypted JSON export. It includes your card and, when available,
|
||||||
the card later.
|
your Nostr npub/nsec so you can import the key into keys.band, a browser extension, or Amber.
|
||||||
</p>
|
</p>
|
||||||
<label class="field-label">
|
<label class="field-label">
|
||||||
Backup password
|
Export password
|
||||||
<input v-model="backupPassword" class="field-input" type="password" autocomplete="new-password" />
|
<input v-model="backupPassword" class="field-input" type="password" autocomplete="new-password" />
|
||||||
</label>
|
</label>
|
||||||
<p v-if="backupError" class="validation-message text-sm text-red-200">{{ backupError }}</p>
|
<p v-if="backupError" class="validation-message text-sm text-red-200">{{ backupError }}</p>
|
||||||
@@ -1193,15 +1521,15 @@ watch(signupStep, async (step) => {
|
|||||||
<div v-if="isRestoreOpen" class="modal-backdrop" @click.self="isRestoreOpen = false">
|
<div v-if="isRestoreOpen" class="modal-backdrop" @click.self="isRestoreOpen = false">
|
||||||
<div class="backup-modal">
|
<div class="backup-modal">
|
||||||
<div class="border-b border-white/10 p-5">
|
<div class="border-b border-white/10 p-5">
|
||||||
<p class="section-kicker">Restore Card</p>
|
<p class="section-kicker">Import Card</p>
|
||||||
<h2 class="text-2xl font-black uppercase leading-none">Encrypted backup</h2>
|
<h2 class="text-2xl font-black uppercase leading-none">Encrypted import</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 p-5">
|
<div class="space-y-4 p-5">
|
||||||
<p class="text-sm leading-6 text-white/62">
|
<p class="text-sm leading-6 text-white/62">
|
||||||
Enter the backup password, then choose the encrypted card backup file.
|
Enter the export password, then choose the encrypted member file.
|
||||||
</p>
|
</p>
|
||||||
<label class="field-label">
|
<label class="field-label">
|
||||||
Backup password
|
Export password
|
||||||
<input v-model="restorePassword" class="field-input" type="password" autocomplete="current-password" />
|
<input v-model="restorePassword" class="field-input" type="password" autocomplete="current-password" />
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -1212,7 +1540,7 @@ watch(signupStep, async (step) => {
|
|||||||
@change="restoreEncryptedBackup"
|
@change="restoreEncryptedBackup"
|
||||||
/>
|
/>
|
||||||
<button class="primary-action w-full" type="button" @click="backupFileInput?.click()">
|
<button class="primary-action w-full" type="button" @click="backupFileInput?.click()">
|
||||||
Choose backup file
|
Choose encrypted file
|
||||||
</button>
|
</button>
|
||||||
<p v-if="backupError" class="validation-message text-sm text-red-200">{{ backupError }}</p>
|
<p v-if="backupError" class="validation-message text-sm text-red-200">{{ backupError }}</p>
|
||||||
<p v-if="backupMessage" class="validation-message text-sm text-emerald-300">{{ backupMessage }}</p>
|
<p v-if="backupMessage" class="validation-message text-sm text-emerald-300">{{ backupMessage }}</p>
|
||||||
|
|||||||
@@ -3,3 +3,11 @@ import App from './App.vue'
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
createApp(App).mount('#app')
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch((error) => {
|
||||||
|
console.warn('Service worker registration failed:', error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
273
src/services/signer.js
Normal file
273
src/services/signer.js
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { SimplePool } from 'nostr-tools/pool'
|
||||||
|
|
||||||
|
const NOSTR_CONNECT_RELAYS = ['wss://relay.primal.net']
|
||||||
|
const NOSTR_CONNECT_TIMEOUT_MS = 120_000
|
||||||
|
const NOSTR_CONNECT_PENDING_KEY = 'l484.nostrconnect.pending'
|
||||||
|
const NOSTR_CONNECT_PENDING_SESSION_KEY = 'l484.nostrconnect.pending.session'
|
||||||
|
const SIGNER_SESSION_KEY = 'l484.signer.session'
|
||||||
|
const HEX_PUBKEY_PATTERN = /^[0-9a-f]{64}$/i
|
||||||
|
const pool = new SimplePool()
|
||||||
|
|
||||||
|
let activeSigner = null
|
||||||
|
|
||||||
|
export const clearSigner = () => {
|
||||||
|
activeSigner = null
|
||||||
|
localStorage.removeItem(SIGNER_SESSION_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loginWithExtension = async () => {
|
||||||
|
if (!window.nostr?.getPublicKey) {
|
||||||
|
throw new Error('No NIP-07 extension found. Try Alby, nos2x, or Primal extension.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubkey = await window.nostr.getPublicKey()
|
||||||
|
activeSigner = {
|
||||||
|
kind: 'extension',
|
||||||
|
getPublicKey: async () => pubkey,
|
||||||
|
signEvent: (template) => window.nostr.signEvent(template),
|
||||||
|
}
|
||||||
|
saveSignerSession({ kind: 'extension', pubkey })
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loginWithRemoteApp = async () => {
|
||||||
|
clearPendingRemoteLogin()
|
||||||
|
return connectRemoteApp({ openApp: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resumeRemoteAppLogin = () => connectRemoteApp({ openApp: false })
|
||||||
|
|
||||||
|
export const hasPendingRemoteAppLogin = () => !!loadPendingRemoteLogin()
|
||||||
|
|
||||||
|
export const cancelPendingRemoteAppLogin = () => {
|
||||||
|
clearPendingRemoteLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const restoreSavedSigner = async () => {
|
||||||
|
if (activeSigner) return true
|
||||||
|
|
||||||
|
const saved = loadSavedSignerSession()
|
||||||
|
if (!saved) return false
|
||||||
|
|
||||||
|
if (saved.kind === 'extension') {
|
||||||
|
if (!window.nostr?.getPublicKey) return false
|
||||||
|
const pubkey = await window.nostr.getPublicKey()
|
||||||
|
if (saved.pubkey !== pubkey) {
|
||||||
|
clearSigner()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
activeSigner = {
|
||||||
|
kind: 'extension',
|
||||||
|
getPublicKey: async () => pubkey,
|
||||||
|
signEvent: (template) => window.nostr.signEvent(template),
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const { NostrConnectSigner, PrivateKeySigner } = await loadNostrConnect()
|
||||||
|
const signer = new NostrConnectSigner({
|
||||||
|
relays: saved.relays,
|
||||||
|
signer: new PrivateKeySigner(new Uint8Array(saved.key)),
|
||||||
|
secret: saved.secret,
|
||||||
|
remote: saved.remote,
|
||||||
|
pubkey: saved.pubkey,
|
||||||
|
subscriptionMethod: subscribeToRelays,
|
||||||
|
publishMethod: publishToRelays,
|
||||||
|
})
|
||||||
|
|
||||||
|
activeSigner = {
|
||||||
|
kind: 'remote',
|
||||||
|
getPublicKey: async () => saved.pubkey,
|
||||||
|
signEvent: (template) => signer.signEvent(template),
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectRemoteApp = async ({ openApp }) => {
|
||||||
|
const { NostrConnectSigner, PrivateKeySigner } = await loadNostrConnect()
|
||||||
|
const pending = loadPendingRemoteLogin()
|
||||||
|
const clientSigner = pending
|
||||||
|
? new PrivateKeySigner(new Uint8Array(pending.key))
|
||||||
|
: new PrivateKeySigner()
|
||||||
|
|
||||||
|
const signer = new NostrConnectSigner({
|
||||||
|
relays: pending?.relays ?? NOSTR_CONNECT_RELAYS,
|
||||||
|
signer: clientSigner,
|
||||||
|
...(pending ? { secret: pending.secret } : {}),
|
||||||
|
subscriptionMethod: subscribeToRelays,
|
||||||
|
publishMethod: publishToRelays,
|
||||||
|
})
|
||||||
|
|
||||||
|
const permissions = [
|
||||||
|
...(NostrConnectSigner.buildSigningPermissions?.([27235, 4]) ?? ['sign_event:27235', 'sign_event:4']),
|
||||||
|
'nip44_encrypt',
|
||||||
|
'nip44_decrypt',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
savePendingRemoteLogin({
|
||||||
|
key: Array.from(clientSigner.key),
|
||||||
|
secret: signer.secret,
|
||||||
|
relays: NOSTR_CONNECT_RELAYS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openApp) {
|
||||||
|
openSignerApp(withCallback(signer.getNostrConnectURI({
|
||||||
|
name: 'L484',
|
||||||
|
url: window.location.origin,
|
||||||
|
permissions,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
const abort = new AbortController()
|
||||||
|
const timeout = window.setTimeout(() => abort.abort(), NOSTR_CONNECT_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
await signer.waitForSigner(abort.signal)
|
||||||
|
if (!signer.remote) throw new Error('Remote signer did not complete the connection.')
|
||||||
|
const pubkey = await signer.getPublicKey()
|
||||||
|
if (!HEX_PUBKEY_PATTERN.test(pubkey)) throw new Error('Remote signer returned an invalid public key.')
|
||||||
|
activeSigner = {
|
||||||
|
kind: 'remote',
|
||||||
|
getPublicKey: async () => pubkey,
|
||||||
|
signEvent: (template) => signer.signEvent(template),
|
||||||
|
}
|
||||||
|
saveSignerSession({
|
||||||
|
kind: 'remote',
|
||||||
|
key: Array.from(clientSigner.key),
|
||||||
|
secret: signer.secret,
|
||||||
|
relays: signer.relays,
|
||||||
|
remote: signer.remote,
|
||||||
|
pubkey,
|
||||||
|
})
|
||||||
|
clearPendingRemoteLogin()
|
||||||
|
return pubkey
|
||||||
|
} catch (error) {
|
||||||
|
clearPendingRemoteLogin()
|
||||||
|
if (error instanceof Error && /aborted/i.test(error.message)) {
|
||||||
|
throw new Error('Signer approval timed out. Open signer app again and approve the L484 connection.')
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
if (!signer.isConnected) await signer.close().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadNostrConnect = async () => {
|
||||||
|
const mod = await import('applesauce-signers')
|
||||||
|
const NostrConnectSigner = mod.NostrConnectSigner ?? mod.default?.NostrConnectSigner
|
||||||
|
const PrivateKeySigner = mod.PrivateKeySigner ?? mod.default?.PrivateKeySigner
|
||||||
|
if (!NostrConnectSigner || !PrivateKeySigner) {
|
||||||
|
throw new Error('Nostr Connect signer support is unavailable.')
|
||||||
|
}
|
||||||
|
return { NostrConnectSigner, PrivateKeySigner }
|
||||||
|
}
|
||||||
|
|
||||||
|
const withCallback = (uri) => {
|
||||||
|
const separator = uri.includes('?') ? '&' : '?'
|
||||||
|
return `${uri}${separator}callback=${encodeURIComponent(`${window.location.origin}/auth/nostr-callback`)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSignerApp = (uri) => {
|
||||||
|
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||||
|
if (isMobile) {
|
||||||
|
window.location.href = uri
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const opened = window.open(uri, '_blank', 'noopener,noreferrer')
|
||||||
|
if (!opened) window.location.href = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPendingRemoteLogin = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(NOSTR_CONNECT_PENDING_KEY) || sessionStorage.getItem(NOSTR_CONNECT_PENDING_SESSION_KEY)
|
||||||
|
const parsed = JSON.parse(raw || 'null')
|
||||||
|
if (!Array.isArray(parsed?.key) || !parsed.secret || !Array.isArray(parsed.relays)) return null
|
||||||
|
return parsed
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePendingRemoteLogin = (pending) => {
|
||||||
|
const serialized = JSON.stringify({ ...pending, startedAt: Date.now() })
|
||||||
|
localStorage.setItem(NOSTR_CONNECT_PENDING_KEY, serialized)
|
||||||
|
sessionStorage.setItem(NOSTR_CONNECT_PENDING_SESSION_KEY, serialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPendingRemoteLogin = () => {
|
||||||
|
localStorage.removeItem(NOSTR_CONNECT_PENDING_KEY)
|
||||||
|
sessionStorage.removeItem(NOSTR_CONNECT_PENDING_KEY)
|
||||||
|
sessionStorage.removeItem(NOSTR_CONNECT_PENDING_SESSION_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSavedSignerSession = () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(SIGNER_SESSION_KEY) || 'null')
|
||||||
|
if (parsed?.kind === 'extension' && parsed.pubkey) return parsed
|
||||||
|
if (
|
||||||
|
parsed?.kind === 'remote' &&
|
||||||
|
Array.isArray(parsed.key) &&
|
||||||
|
parsed.secret &&
|
||||||
|
Array.isArray(parsed.relays) &&
|
||||||
|
parsed.remote &&
|
||||||
|
parsed.pubkey
|
||||||
|
) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSignerSession = (session) => {
|
||||||
|
localStorage.setItem(SIGNER_SESSION_KEY, JSON.stringify(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeToRelays = (relays, filters) => ({
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
const queue = []
|
||||||
|
let wake = null
|
||||||
|
let closed = false
|
||||||
|
const sub = pool.subscribeMany(relays, filters, {
|
||||||
|
onevent(event) {
|
||||||
|
queue.push(event)
|
||||||
|
wake?.()
|
||||||
|
},
|
||||||
|
onclose(reasons) {
|
||||||
|
for (const reason of reasons) queue.push(reason)
|
||||||
|
closed = true
|
||||||
|
wake?.()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
async next() {
|
||||||
|
while (!queue.length && !closed) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
wake = resolve
|
||||||
|
})
|
||||||
|
wake = null
|
||||||
|
}
|
||||||
|
const value = queue.shift()
|
||||||
|
if (value) return { value, done: false }
|
||||||
|
return { value: undefined, done: true }
|
||||||
|
},
|
||||||
|
async return() {
|
||||||
|
closed = true
|
||||||
|
sub.close()
|
||||||
|
wake?.()
|
||||||
|
return { value: undefined, done: true }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const publishToRelays = async (relays, event) => {
|
||||||
|
const results = await Promise.allSettled(pool.publish(relays, event))
|
||||||
|
if (!results.some((result) => result.status === 'fulfilled')) {
|
||||||
|
throw new Error('Could not publish Nostr Connect request to relay.')
|
||||||
|
}
|
||||||
|
}
|
||||||
262
src/style.css
262
src/style.css
@@ -36,9 +36,70 @@ body {
|
|||||||
animation: rise-in 900ms cubic-bezier(0.19, 1, 0.22, 1) 700ms both;
|
animation: rise-in 900ms cubic-bezier(0.19, 1, 0.22, 1) 700ms both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animated-header-logo {
|
||||||
|
position: relative;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: max-content;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-header-logo img {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
animation: header-logo-fill 10s cubic-bezier(0.19, 1, 0.22, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-header-logo::after {
|
||||||
|
content: "";
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 145%;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
105deg,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 38%,
|
||||||
|
rgba(250, 250, 250, 0.72) 48%,
|
||||||
|
rgba(242, 169, 0, 0.58) 52%,
|
||||||
|
transparent 64%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
filter: blur(0.18rem);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-135%) skewX(-14deg);
|
||||||
|
animation: header-logo-sweep 10s cubic-bezier(0.19, 1, 0.22, 1) infinite;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo-outline {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
width: auto;
|
||||||
|
height: 1rem;
|
||||||
|
overflow: visible;
|
||||||
|
fill: none;
|
||||||
|
stroke: #fafafa;
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 34;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 0.18rem rgba(250, 250, 250, 0.62))
|
||||||
|
drop-shadow(0 0 0.55rem rgba(242, 169, 0, 0.28));
|
||||||
|
opacity: 0;
|
||||||
|
animation: header-logo-outline 10s cubic-bezier(0.19, 1, 0.22, 1) infinite;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.header-logo-outline {
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.intro-copy {
|
.intro-copy {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
animation: copy-in 1100ms cubic-bezier(0.19, 1, 0.22, 1) 920ms both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
@@ -48,7 +109,17 @@ body {
|
|||||||
|
|
||||||
.hero-title-line {
|
.hero-title-line {
|
||||||
display: block;
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
will-change: clip-path, filter, opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title-line-primary {
|
||||||
|
animation: cinematic-word-in 1250ms cubic-bezier(0.19, 1, 0.22, 1) 760ms both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title-line-secondary {
|
||||||
|
animation: cinematic-word-in 1250ms cubic-bezier(0.19, 1, 0.22, 1) 1420ms both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.film-grain {
|
.film-grain {
|
||||||
@@ -96,6 +167,18 @@ body {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ghost-member-button {
|
||||||
|
border-color: rgba(250, 250, 250, 0.36);
|
||||||
|
background: transparent;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-member-button:hover {
|
||||||
|
border-color: rgba(250, 250, 250, 0.62);
|
||||||
|
background: rgba(250, 250, 250, 0.08);
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
.secondary-action,
|
.secondary-action,
|
||||||
.delete-member {
|
.delete-member {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
@@ -104,6 +187,29 @@ body {
|
|||||||
padding: 0.72rem 0.92rem;
|
padding: 0.72rem 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.signin-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-option {
|
||||||
|
display: flex;
|
||||||
|
min-height: 4.4rem;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.24rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-option small {
|
||||||
|
color: rgba(8, 8, 8, 0.68);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-close {
|
.modal-close {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -176,6 +282,61 @@ body {
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-keys {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.055);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-keys div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-keys a {
|
||||||
|
color: #f2a900;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-key-row {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-key-row span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-keys span {
|
||||||
|
color: rgba(255, 255, 255, 0.42);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-keys code {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.34);
|
||||||
|
padding: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-key-row .secondary-action {
|
||||||
|
display: grid;
|
||||||
|
width: 4.25rem;
|
||||||
|
min-width: 4.25rem;
|
||||||
|
place-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
padding-inline: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-footer-actions {
|
.modal-footer-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -968,6 +1129,105 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes cinematic-word-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
clip-path: inset(0 100% 0 0);
|
||||||
|
filter: blur(0.5rem) saturate(0.72);
|
||||||
|
transform: translate3d(0, 1.1rem, 0) scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
42% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0.08rem) saturate(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
filter: blur(0) saturate(1);
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes header-logo-outline {
|
||||||
|
0%,
|
||||||
|
68%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
72% {
|
||||||
|
opacity: 0;
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
76% {
|
||||||
|
opacity: 1;
|
||||||
|
stroke-dashoffset: 0.98;
|
||||||
|
}
|
||||||
|
|
||||||
|
87% {
|
||||||
|
opacity: 1;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
93% {
|
||||||
|
opacity: 0;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes header-logo-fill {
|
||||||
|
0%,
|
||||||
|
68%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
transform: translateZ(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
72% {
|
||||||
|
opacity: 0.48;
|
||||||
|
filter: brightness(0.72) blur(0.01rem);
|
||||||
|
transform: translateZ(0) scale(0.992);
|
||||||
|
}
|
||||||
|
|
||||||
|
86% {
|
||||||
|
opacity: 0.28;
|
||||||
|
filter: brightness(0.9) blur(0.015rem);
|
||||||
|
transform: translateZ(0) scale(0.992);
|
||||||
|
}
|
||||||
|
|
||||||
|
94% {
|
||||||
|
opacity: 1;
|
||||||
|
filter:
|
||||||
|
brightness(1.18)
|
||||||
|
drop-shadow(0 0 0.45rem rgba(250, 250, 250, 0.42))
|
||||||
|
drop-shadow(0 0 0.9rem rgba(242, 169, 0, 0.32));
|
||||||
|
transform: translateZ(0) scale(1.012);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes header-logo-sweep {
|
||||||
|
0%,
|
||||||
|
83%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-135%) skewX(-14deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
88% {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
94% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(135%) skewX(-14deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes rise-in {
|
@keyframes rise-in {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -3,4 +3,9 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:3001',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user