Add membership backend and signer login
This commit is contained in:
422
src/App.vue
422
src/App.vue
@@ -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,18 +499,30 @@ const createMembership = () => {
|
||||
status: 'active',
|
||||
}
|
||||
|
||||
members.value = [member, ...members.value]
|
||||
currentMemberId.value = member.membershipId
|
||||
createdMember.value = member
|
||||
localStorage.setItem(CURRENT_MEMBER_KEY, member.membershipId)
|
||||
saveMembers()
|
||||
resetForm()
|
||||
isCardRevealing.value = true
|
||||
signupStep.value = 4
|
||||
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
|
||||
signupStep.value = 4
|
||||
|
||||
window.setTimeout(() => {
|
||||
isCardRevealing.value = false
|
||||
}, 2400)
|
||||
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>
|
||||
|
||||
@@ -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
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user