Add membership backend and signer login

This commit is contained in:
Dorian
2026-05-13 22:19:37 -05:00
parent 91245e64c5
commit c015f6b7da
15 changed files with 1522 additions and 98 deletions

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@ node_modules
dist
.DS_Store
*.local
data
server/data

View File

@@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8" />
<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>
<body>
<div id="app"></div>

260
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@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",
"vue": "^3.5.13"
},
@@ -533,44 +534,62 @@
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"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": {
"node": ">= 20.19.0"
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
"node": ">= 16"
},
"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": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -935,36 +954,51 @@
]
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"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",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
"@noble/hashes": "~1.3.0",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -1110,6 +1144,100 @@
"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": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -1326,6 +1454,23 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"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": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -1426,6 +1571,12 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"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": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1530,6 +1681,12 @@
"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": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -1669,6 +1826,12 @@
"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": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -1717,17 +1880,19 @@
}
},
"node_modules/nostr-tools": {
"version": "2.23.3",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.3.tgz",
"integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==",
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz",
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "2.1.1",
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0",
"@scure/bip32": "2.0.1",
"@scure/bip39": "2.0.1",
"@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"
},
"optionalDependencies": {
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
@@ -2119,6 +2284,15 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2291,6 +2465,12 @@
"dev": true,
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -4,13 +4,17 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "node server/dev.js",
"dev:vite": "vite --host",
"dev:server": "node server/server.js",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"start": "node server/server.js"
},
"dependencies": {
"@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",
"vue": "^3.5.13"
},

View File

@@ -1,8 +1,8 @@
<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="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="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="M4028.38 0H4394.72V854.875H4516.84V977H4761.07V0H5127.42V1954H4761.07V1099.13H4516.84V977H4272.61V854.875H4150.49V732.75H4028.38V0Z" 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="#F2A900"/>
<path d="M6713.96 0H7080.31V854.875H7202.42V977H7446.65V0H7813V1954H7446.65V1099.13H7202.42V977H6958.19V854.875H6836.08V732.75H6713.96V0Z" 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="#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="#FAFAFA"/>
<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="#FAFAFA"/>
<path d="M6713.96 0H7080.31V854.875H7202.42V977H7446.65V0H7813V1954H7446.65V1099.13H7202.42V977H6958.19V854.875H6836.08V732.75H6713.96V0Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View 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
View 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
View 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
View 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
View 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}`)
})

View File

@@ -1,6 +1,14 @@
<script setup>
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(
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 CURRENT_MEMBER_KEY = 'l484-current-member'
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 = [
'7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617',
'2be35e9237eaf98fe0a7d1ce3ceb666dccde9c8cc800ff52f138a62c91d90783',
@@ -43,11 +54,17 @@ const covenantItems = [
const activeBackground = ref(0)
const hasRotatingBackgrounds = computed(() => heroBackgrounds.length > 1)
const isSignupOpen = ref(false)
const isMemberSigninOpen = ref(false)
const signupStep = ref(0)
const members = ref([])
const currentMemberId = ref('')
const createdMember = ref(null)
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 signatureCanvas = ref(null)
const signatureHasInk = ref(false)
@@ -115,6 +132,62 @@ const sanitizeText = (value, maxLength) =>
.trim()
.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 = () => {
form.fullName = sanitizeText(form.fullName, MAX_NAME_LENGTH)
form.email = sanitizeText(form.email, MAX_EMAIL_LENGTH).toLowerCase()
@@ -160,6 +233,9 @@ const normalizeMember = (value) => {
const signedDate = String(value.signedDate || '')
const createdAt = String(value.createdAt || '')
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 (fullName.length < 2) return null
@@ -173,6 +249,9 @@ const normalizeMember = (value) => {
fullName,
email,
phone,
userId,
npub,
nsecHash,
signature,
signedDate,
createdAt,
@@ -181,7 +260,7 @@ const normalizeMember = (value) => {
}
}
const loadMembers = () => {
const loadMembers = async () => {
try {
const parsed = JSON.parse(localStorage.getItem(MEMBERS_KEY) || '[]')
members.value = Array.isArray(parsed) ? parsed.map(normalizeMember).filter(Boolean) : []
@@ -191,6 +270,31 @@ const loadMembers = () => {
members.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 = () => {
@@ -211,9 +315,15 @@ const openSignup = () => {
signupStep.value = currentMember.value ? 4 : 0
createdMember.value = currentMember.value
isCardRevealing.value = false
generatedCredentials.value = null
formError.value = ''
}
const openMemberSignin = () => {
isMemberSigninOpen.value = true
memberSigninError.value = ''
}
const navigateTo = (path) => {
window.history.pushState({}, '', path)
currentPath.value = window.location.pathname
@@ -222,6 +332,28 @@ const navigateTo = (path) => {
const closeSignup = () => {
isSignupOpen.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 = () => {
@@ -254,7 +386,86 @@ const previousStep = () => {
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()
if (!validateApplicant()) {
return
@@ -268,12 +479,19 @@ const createMembership = () => {
const createdAt = new Date()
const expiresAt = new Date(createdAt)
expiresAt.setFullYear(expiresAt.getFullYear() + 1)
const privateKey = generateSecretKey()
const pubkey = getPublicKey(privateKey)
const nsec = nip19.nsecEncode(privateKey)
const npub = nip19.npubEncode(pubkey)
const member = {
membershipId: `L484-${createdAt.getFullYear()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`,
userId: getUserId(),
fullName: form.fullName.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
npub,
nsecHash: await sha256Hex(nsec),
signature: form.signature.trim(),
signedDate: createdAt.toISOString(),
createdAt: createdAt.toISOString(),
@@ -281,10 +499,19 @@ const createMembership = () => {
status: 'active',
}
members.value = [member, ...members.value]
currentMemberId.value = member.membershipId
createdMember.value = member
localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
try {
const result = await fetchJson('/api/membership/create', {
method: 'POST',
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()
resetForm()
isCardRevealing.value = true
@@ -293,6 +520,9 @@ const createMembership = () => {
window.setTimeout(() => {
isCardRevealing.value = false
}, 2400)
} catch (error) {
formError.value = error instanceof Error ? error.message : 'Could not save membership.'
}
}
const syncSignatureCanvas = () => {
@@ -352,7 +582,18 @@ const clearSignature = () => {
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)
if (currentMemberId.value === membershipId) {
currentMemberId.value = ''
@@ -399,6 +640,7 @@ const loginWithNip07 = async () => {
}
const pubkey = await window.nostr.getPublicKey()
setAdminSession(pubkey)
await loadMembers()
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nostr extension login failed.'
} finally {
@@ -425,6 +667,7 @@ const loginWithNsec = async () => {
const privateKey = decoded.data
const pubkey = getPublicKey(privateKey instanceof Uint8Array ? privateKey : bytesToHex(privateKey))
setAdminSession(pubkey)
await loadMembers()
adminNsec.value = ''
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nsec login failed.'
@@ -486,12 +729,12 @@ const downloadEncryptedBackup = async () => {
backupMessage.value = ''
if (!currentMember.value) {
backupError.value = 'Create a membership card before downloading a backup.'
backupError.value = 'Create a membership card before exporting.'
return
}
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
}
@@ -501,9 +744,10 @@ const downloadEncryptedBackup = async () => {
const key = await deriveBackupKey(backupPassword.value, salt)
const payload = {
type: 'l484-membership-card-backup',
version: 1,
version: 2,
exportedAt: new Date().toISOString(),
member: currentMember.value,
keys: generatedCredentials.value || loadStoredMemberKeys(),
}
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
@@ -523,13 +767,13 @@ const downloadEncryptedBackup = async () => {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${currentMember.value.membershipId}-encrypted-card-backup.json`
anchor.download = `${currentMember.value.membershipId}-encrypted-member-export.json`
anchor.click()
URL.revokeObjectURL(url)
backupMessage.value = 'Encrypted card backup created.'
backupMessage.value = 'Encrypted member export created.'
isBackupOpen.value = false
} 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 (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
}
try {
const backup = JSON.parse(await file.text())
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)
@@ -565,12 +809,17 @@ const restoreEncryptedBackup = async (event) => {
members.value = [member, ...members.value.filter((item) => item.membershipId !== member.membershipId)]
currentMemberId.value = member.membershipId
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)
if (member.userId) localStorage.setItem(USER_ID_KEY, member.userId)
saveMembers()
backupMessage.value = 'Encrypted card backup restored.'
backupMessage.value = 'Encrypted member file imported.'
isRestoreOpen.value = false
} 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(() => {
loadMembers()
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', () => {
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)
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">
<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">
<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">
{{ currentMember ? 'View card' : 'Become a member' }}
</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="intro-copy">
<h1 class="hero-title font-black uppercase leading-[0.86] tracking-normal">
<span class="hero-title-line">Decentralization</span>
<span class="hero-title-line">in motion.</span>
<span class="hero-title-line hero-title-line-primary">Decentralization</span>
<span class="hero-title-line hero-title-line-secondary">In Motion</span>
</h1>
</div>
</div>
@@ -861,7 +1140,7 @@ watch(signupStep, async (step) => {
<p class="section-kicker">Admin Panel</p>
<h2 class="section-title">Members</h2>
<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>
</div>
<div class="space-y-3">
@@ -932,6 +1211,34 @@ watch(signupStep, async (step) => {
</div>
</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 class="agreement-modal">
<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.
</p>
<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>
@@ -1060,9 +1367,9 @@ watch(signupStep, async (step) => {
<li v-for="item in covenantItems" :key="item">{{ item }}</li>
</ol>
</div>
<label class="flex items-start gap-3 text-sm text-white/75">
<input v-model="form.accepted" class="mt-1 h-4 w-4 accent-amber-400" type="checkbox" />
I have read and agree to the L484 Membership Covenant.
<label class="flex items-center gap-3 text-sm leading-none text-white/75">
<input v-model="form.accepted" class="h-4 w-4 shrink-0 accent-amber-400" type="checkbox" />
<span>I have read and agree to the L484 Membership Covenant.</span>
</label>
</div>
@@ -1127,8 +1434,29 @@ watch(signupStep, async (step) => {
</div>
</div>
<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>
<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>
<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">
<template v-if="signupStep === 4">
<button class="secondary-action" type="button" @click="openBackup">
Backup
Export
</button>
<button class="secondary-action" type="button" @click="openRestore">
Restore
Import
</button>
</template>
<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 class="backup-modal">
<div class="border-b border-white/10 p-5">
<p class="section-kicker">Encrypted Backup</p>
<h2 class="text-2xl font-black uppercase leading-none">Protect card backup</h2>
<p class="section-kicker">Encrypted Export</p>
<h2 class="text-2xl font-black uppercase leading-none">Export member file</h2>
</div>
<div class="space-y-4 p-5">
<p class="text-sm leading-6 text-white/62">
Choose a password for the encrypted JSON backup. You will need this password to restore
the card later.
Choose a password for the encrypted JSON export. It includes your card and, when available,
your Nostr npub/nsec so you can import the key into keys.band, a browser extension, or Amber.
</p>
<label class="field-label">
Backup password
Export password
<input v-model="backupPassword" class="field-input" type="password" autocomplete="new-password" />
</label>
<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 class="backup-modal">
<div class="border-b border-white/10 p-5">
<p class="section-kicker">Restore Card</p>
<h2 class="text-2xl font-black uppercase leading-none">Encrypted backup</h2>
<p class="section-kicker">Import Card</p>
<h2 class="text-2xl font-black uppercase leading-none">Encrypted import</h2>
</div>
<div class="space-y-4 p-5">
<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>
<label class="field-label">
Backup password
Export password
<input v-model="restorePassword" class="field-input" type="password" autocomplete="current-password" />
</label>
<input
@@ -1212,7 +1540,7 @@ watch(signupStep, async (step) => {
@change="restoreEncryptedBackup"
/>
<button class="primary-action w-full" type="button" @click="backupFileInput?.click()">
Choose backup file
Choose encrypted file
</button>
<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>

View File

@@ -3,3 +3,11 @@ import App from './App.vue'
import './style.css'
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
View 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.')
}
}

View File

@@ -36,9 +36,70 @@ body {
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 {
max-width: 100%;
animation: copy-in 1100ms cubic-bezier(0.19, 1, 0.22, 1) 920ms both;
}
.hero-title {
@@ -48,7 +109,17 @@ body {
.hero-title-line {
display: block;
overflow: hidden;
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 {
@@ -96,6 +167,18 @@ body {
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,
.delete-member {
border: 1px solid rgba(255, 255, 255, 0.16);
@@ -104,6 +187,29 @@ body {
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 {
position: relative;
display: grid;
@@ -176,6 +282,61 @@ body {
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 {
display: grid;
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 {
0% {
opacity: 0;

View File

@@ -3,4 +3,9 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://127.0.0.1:3001',
},
},
})