@ -4,12 +4,28 @@ function App() {
@@ -4,12 +4,28 @@ function App() {
const [ user , setUser ] = useState ( null ) ;
const [ status , setStatus ] = useState ( 'Ready to authenticate' ) ;
const [ statusType , setStatusType ] = useState ( 'info' ) ;
const [ profileData , setProfileData ] = useState ( null ) ;
useEffect ( ( ) => {
/ / C h e c k a u t h e n t i c a t i o n s t a t u s o n p a g e l o a d
checkStatus ( ) ;
} , [ ] ) ;
/ / E f f e c t t o f e t c h p r o f i l e w h e n u s e r c h a n g e s
useEffect ( ( ) => {
if ( user ? . pubkey ) {
fetchUserProfile ( user . pubkey ) ;
}
} , [ user ? . pubkey ] ) ;
function relayURL ( ) {
try {
return window . location . protocol . replace ( 'http' , 'ws' ) + '//' + window . location . host ;
} catch ( _ ) {
return 'ws://localhost:3333' ;
}
}
async function checkStatus ( ) {
try {
const response = await fetch ( '/api/auth/status' ) ;
@ -17,7 +33,7 @@ function App() {
@@ -17,7 +33,7 @@ function App() {
if ( data . authenticated ) {
setUser ( data . pubkey ) ;
updateStatus ( ` Already authenticated as: ${ data . pubkey . slice ( 0 , 16 ) } ... ` , 'success' ) ;
/ / C h e c k p e r m i s s i o n s i f a u t h e n t i c a t e d
if ( data . pubkey ) {
const permResponse = await fetch ( ` /api/permissions/ ${ data . pubkey } ` ) ;
@ -37,6 +53,19 @@ function App() {
@@ -37,6 +53,19 @@ function App() {
setStatusType ( type ) ;
}
function statusClassName ( ) {
const base = 'mt-5 mb-5 p-3 rounded' ;
switch ( statusType ) {
case 'success' :
return base + ' bg-green-100 text-green-800' ;
case 'error' :
return base + ' bg-red-100 text-red-800' ;
case 'info' :
default :
return base + ' bg-cyan-100 text-cyan-800' ;
}
}
async function getChallenge ( ) {
try {
const response = await fetch ( '/api/auth/challenge' ) ;
@ -53,38 +82,199 @@ function App() {
@@ -53,38 +82,199 @@ function App() {
updateStatus ( 'No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.' , 'error' ) ;
return ;
}
try {
updateStatus ( 'Connecting to extension...' , 'info' ) ;
/ / G e t p u b l i c k e y f r o m e x t e n s i o n
const pubkey = await window . nostr . getPublicKey ( ) ;
/ / G e t c h a l l e n g e f r o m s e r v e r
const challenge = await getChallenge ( ) ;
/ / C r e a t e a u t h e n t i c a t i o n e v e n t
const authEvent = {
kind : 22242 ,
created _at : Math . floor ( Date . now ( ) / 1000 ) ,
tags : [
[ 'relay' , window . location . protocol . replace ( 'http' , 'ws' ) + '//' + window . location . host ] ,
[ 'relay' , relayURL ( ) ] ,
[ 'challenge' , challenge ]
] ,
content : ''
} ;
/ / S i g n t h e e v e n t w i t h e x t e n s i o n
const signedEvent = await window . nostr . signEvent ( authEvent ) ;
/ / S e n d t o s e r v e r
await authenticate ( signedEvent ) ;
} catch ( error ) {
updateStatus ( 'Extension login failed: ' + error . message , 'error' ) ;
}
}
async function fetchKind0FromRelay ( pubkeyHex , timeoutMs = 4000 ) {
return new Promise ( ( resolve ) => {
let resolved = false ;
let events = [ ] ;
let ws ;
try {
ws = new WebSocket ( relayURL ( ) ) ;
} catch ( e ) {
resolve ( null ) ;
return ;
}
const subId = 'profile-' + Math . random ( ) . toString ( 36 ) . slice ( 2 ) ;
const timer = setTimeout ( ( ) => {
if ( ws && ws . readyState === 1 ) {
try { ws . close ( ) ; } catch ( _ ) { }
}
if ( ! resolved ) {
resolved = true ;
resolve ( null ) ;
}
} , timeoutMs ) ;
ws . onopen = ( ) => {
try {
const req = [
'REQ' ,
subId ,
{ kinds : [ 0 ] , authors : [ pubkeyHex ] }
] ;
ws . send ( JSON . stringify ( req ) ) ;
} catch ( _ ) { }
} ;
ws . onmessage = ( msg ) => {
try {
const data = JSON . parse ( msg . data ) ;
const type = data [ 0 ] ;
if ( type === 'EVENT' && data [ 1 ] === subId ) {
const event = data [ 2 ] ;
if ( event && event . kind === 0 && event . content ) {
events . push ( event ) ;
}
} else if ( type === 'EOSE' && data [ 1 ] === subId ) {
try {
ws . send ( JSON . stringify ( [ 'CLOSE' , subId ] ) ) ;
} catch ( _ ) { }
try { ws . close ( ) ; } catch ( _ ) { }
clearTimeout ( timer ) ;
if ( ! resolved ) {
resolved = true ;
if ( events . length ) {
const latest = events . reduce ( ( a , b ) => ( a . created _at > b . created _at ? a : b ) ) ;
try {
const meta = JSON . parse ( latest . content ) ;
resolve ( meta || null ) ;
} catch ( _ ) {
resolve ( null ) ;
}
} else {
resolve ( null ) ;
}
}
}
} catch ( _ ) {
/ / i g n o r e m a l f o r m e d m e s s a g e s
}
} ;
ws . onerror = ( ) => {
try { ws . close ( ) ; } catch ( _ ) { }
clearTimeout ( timer ) ;
if ( ! resolved ) {
resolved = true ;
resolve ( null ) ;
}
} ;
ws . onclose = ( ) => {
clearTimeout ( timer ) ;
if ( ! resolved ) {
resolved = true ;
if ( events . length ) {
const latest = events . reduce ( ( a , b ) => ( a . created _at > b . created _at ? a : b ) ) ;
try {
const meta = JSON . parse ( latest . content ) ;
resolve ( meta || null ) ;
} catch ( _ ) {
resolve ( null ) ;
}
} else {
resolve ( null ) ;
}
}
} ;
} ) ;
}
/ / F u n c t i o n t o f e t c h u s e r p r o f i l e m e t a d a t a ( k i n d 0 )
async function fetchUserProfile ( pubkeyHex ) {
try {
/ / C r e a t e a s i m p l e p l a c e h o l d e r w i t h t h e p u b k e y
const placeholderProfile = {
name : ` user: ${ pubkeyHex . slice ( 0 , 8 ) } ` ,
about : 'No profile data available'
} ;
/ / A l w a y s s e t t h e p l a c e h o l d e r p r o f i l e f i r s t
setProfileData ( placeholderProfile ) ;
/ / F i r s t , t r y t o g e t p r o f i l e k i n d : 0 f r o m t h e r e l a y i t s e l f
let relayMetadata = null ;
try {
relayMetadata = await fetchKind0FromRelay ( pubkeyHex ) ;
} catch ( _ ) { }
if ( relayMetadata ) {
const parsed = typeof relayMetadata === 'string' ? JSON . parse ( relayMetadata ) : relayMetadata ;
setProfileData ( {
name : parsed . name || placeholderProfile . name ,
display _name : parsed . display _name ,
picture : parsed . picture ,
banner : parsed . banner ,
about : parsed . about || placeholderProfile . about
} ) ;
return parsed ;
}
/ / F a l l b a c k : t r y e x t e n s i o n m e t a d a t a i f a v a i l a b l e
if ( window . nostr && window . nostr . getPublicKey ) {
try {
if ( window . nostr . getUserMetadata ) {
const metadata = await window . nostr . getUserMetadata ( ) ;
if ( metadata ) {
try {
const parsedMetadata = typeof metadata === 'string' ? JSON . parse ( metadata ) : metadata ;
setProfileData ( {
name : parsedMetadata . name || placeholderProfile . name ,
display _name : parsedMetadata . display _name ,
picture : parsedMetadata . picture ,
banner : parsedMetadata . banner ,
about : parsedMetadata . about || placeholderProfile . about
} ) ;
return parsedMetadata ;
} catch ( parseError ) {
console . log ( 'Error parsing user metadata:' , parseError ) ;
}
}
}
} catch ( nostrError ) {
console . log ( 'Could not get profile from extension:' , nostrError ) ;
}
}
return placeholderProfile ;
} catch ( error ) {
console . error ( 'Error handling profile data:' , error ) ;
return null ;
}
}
async function authenticate ( signedEvent ) {
try {
const response = await fetch ( '/api/auth/login' , {
@ -92,18 +282,21 @@ function App() {
@@ -92,18 +282,21 @@ function App() {
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( signedEvent )
} ) ;
const result = await response . json ( ) ;
if ( result . success ) {
setUser ( result . pubkey ) ;
updateStatus ( 'Successfully authenticated as: ' + result . pubkey . slice ( 0 , 16 ) + '...' , 'success' ) ;
/ / C h e c k p e r m i s s i o n s a f t e r l o g i n
const permResponse = await fetch ( ` /api/permissions/ ${ result . pubkey } ` ) ;
const permData = await permResponse . json ( ) ;
if ( permData && permData . permission ) {
setUser ( { pubkey : result . pubkey , permission : permData . permission } ) ;
/ / F e t c h u s e r p r o f i l e d a t a
await fetchUserProfile ( result . pubkey ) ;
}
} else {
updateStatus ( 'Authentication failed: ' + result . error , 'error' ) ;
@ -119,40 +312,58 @@ function App() {
@@ -119,40 +312,58 @@ function App() {
}
return (
< div className = "container" >
{ user ? . permission && (
< div className = "header-panel" >
< div className = "header-content" >
< img src = "/docs/orly.png" alt = "Logo" className = "header-logo" / >
< div className = "user-info" >
{ user . permission === "admin" ? "Admin Dashboard" : "Subscriber Dashboard" }
< >
{ user ? . permission ? (
/ / L o g g e d i n v i e w w i t h u s e r p r o f i l e
< div className = "sticky top-0 left-0 w-full bg-gray-100 z-50 h-16 flex items-center overflow-hidden" >
< div className = "flex items-center h-full w-full box-border" >
< div className = "flex items-center justify-start h-full" >
< img src = "/docs/orly.png" alt = "Logo" className = "h-full aspect-square w-auto object-cover shrink-0" / >
< / div >
< div className = "relative overflow-hidden flex flex-grow items-center justify-start h-full" >
{ profileData ? . banner && (
< div className = "absolute inset-0 opacity-70 bg-cover bg-center" style = { { backgroundImage : ` url( ${ profileData . banner } ) ` } } > < / div >
) }
< div className = "relative z-10 p-2 flex items-center h-full" >
{ profileData ? . picture && < img src = { profileData . picture } alt = "User Avatar" className = "h-full aspect-square w-auto rounded-full object-cover border-2 border-white mr-2 shadow box-border" / > }
< div >
< div className = "font-bold text-base block" >
{ profileData ? . display _name || profileData ? . name || user . pubkey . slice ( 0 , 8 ) }
{ profileData ? . name && profileData ? . display _name && ` ( ${ profileData . name } ) ` }
< / div >
< div className = "font-bold text-lg text-left" >
{ user . permission === "admin" ? "Admin Dashboard" : "Subscriber Dashboard" }
< / div >
< / div >
< / div >
< / div >
< div className = "flex items-center justify-end h-full" >
< button className = "bg-transparent text-gray-500 border-0 text-2xl cursor-pointer p-0 flex items-center justify-center w-12 h-full ml-2 mr-0 shrink-0 hover:bg-transparent hover:text-gray-800" onClick = { logout } > ✕ < / button >
< / div >
< button className = "logout-button" onClick = { logout } > ✕ < / button >
< / div >
< / div >
) }
) : (
/ / N o t l o g g e d i n v i e w - s h o w s t h e l o g i n f o r m
< div className = "max-w-3xl mx-auto mt-5 p-6 bg-gray-100 rounded" >
< h1 className = "text-2xl font-bold mb-2" > Nostr Relay Authentication < / h1 >
< p className = "mb-4" > Connect to this Nostr relay using your private key or browser extension . < / p >
< div className = { statusClassName ( ) } >
{ status }
< / div >
< div className = "mb-5" >
< button className = "bg-blue-600 text-white px-5 py-3 rounded hover:bg-blue-700" onClick = { loginWithExtension } > Login with Browser Extension ( NIP - 07 ) < / button >
< / div >
< h1 > Nostr Relay Authentication < / h1 >
< p > Connect to this Nostr relay using your private key or browser extension . < / p >
< div className = { ` status ${ statusType } ` } >
{ status }
< / div >
< div className = "form-group" >
< button onClick = { loginWithExtension } > Login with Browser Extension ( NIP - 07 ) < / button >
< / div >
< div className = "form-group" >
< label htmlFor = "nsec" > Or login with private key ( nsec ) : < / label >
< input type = "password" id = "nsec" placeholder = "nsec1..." / >
< button onClick = { ( ) => updateStatus ( 'Private key login not implemented in this basic interface. Please use a proper Nostr client or extension.' , 'error' ) } style = { { marginTop : '10px' } } > Login with Private Key < / button >
< / div >
< div className = "form-group" >
< button onClick = { logout } className = "danger-button" > Logout < / button >
< / div >
< / div >
< div className = "mb-5" >
< label className = "block mb-1 font-bold" htmlFor = "nsec" > Or login with private key ( nsec ) : < / label >
< input className = "w-full p-2 border border-gray-300 rounded" type = "password" id = "nsec" placeholder = "nsec1..." / >
< button className = "mt-2 bg-red-600 text-white px-5 py-2 rounded hover:bg-red-700" onClick = { ( ) => updateStatus ( 'Private key login not implemented in this basic interface. Please use a proper Nostr client or extension.' , 'error' ) } > Login with Private Key < / button >
< / div >
< / div >
) }
< / >
) ;
}