Embed React app and add new user authentication interface.
- Integrated a React-based web frontend into the Go server using the `embed` package, serving it from `/`. - Added build and development scripts utilizing Bun for the React app (`package.json`, `README.md`). - Enhanced auth interface to support better user experience and permissions (`App.jsx`, CSS updates). - Refactored `/api/auth/login` to serve React UI, removing hardcoded HTML template. - Implemented `/api/permissions/` with ACL support for user access management.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -92,6 +92,10 @@ cmd/benchmark/data
|
|||||||
!strfry.conf
|
!strfry.conf
|
||||||
!config.toml
|
!config.toml
|
||||||
!.dockerignore
|
!.dockerignore
|
||||||
|
!*.jsx
|
||||||
|
!*.tsx
|
||||||
|
!/dist
|
||||||
|
!bun.lock
|
||||||
# ...even if they are in subdirectories
|
# ...even if they are in subdirectories
|
||||||
!*/
|
!*/
|
||||||
/blocklist.json
|
/blocklist.json
|
||||||
|
|||||||
212
app/server.go
212
app/server.go
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"lol.mleku.dev/chk"
|
"lol.mleku.dev/chk"
|
||||||
"next.orly.dev/app/config"
|
"next.orly.dev/app/config"
|
||||||
|
"next.orly.dev/pkg/acl"
|
||||||
"next.orly.dev/pkg/database"
|
"next.orly.dev/pkg/database"
|
||||||
"next.orly.dev/pkg/encoders/event"
|
"next.orly.dev/pkg/encoders/event"
|
||||||
"next.orly.dev/pkg/encoders/hex"
|
"next.orly.dev/pkg/encoders/hex"
|
||||||
@@ -101,173 +102,16 @@ func (s *Server) UserInterface() {
|
|||||||
s.mux.HandleFunc("/api/auth/challenge", s.handleAuthChallenge)
|
s.mux.HandleFunc("/api/auth/challenge", s.handleAuthChallenge)
|
||||||
s.mux.HandleFunc("/api/auth/login", s.handleAuthLogin)
|
s.mux.HandleFunc("/api/auth/login", s.handleAuthLogin)
|
||||||
s.mux.HandleFunc("/api/auth/status", s.handleAuthStatus)
|
s.mux.HandleFunc("/api/auth/status", s.handleAuthStatus)
|
||||||
|
s.mux.HandleFunc("/api/permissions/", s.handlePermissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLoginInterface serves the main user interface for login
|
// handleLoginInterface serves the main user interface for login
|
||||||
func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
// Create a file server handler for the embedded React app
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
fileServer := http.FileServer(GetReactAppFS())
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
html := `<!DOCTYPE html>
|
// Serve the React app files
|
||||||
<html>
|
fileServer.ServeHTTP(w, r)
|
||||||
<head>
|
|
||||||
<title>Nostr Relay Login</title>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
|
||||||
.container { background: #f9f9f9; padding: 30px; border-radius: 8px; }
|
|
||||||
.form-group { margin-bottom: 20px; }
|
|
||||||
label { display: block; margin-bottom: 5px; font-weight: bold; }
|
|
||||||
input, textarea { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
|
|
||||||
button { background: #007cba; color: white; padding: 12px 20px; border: none; border-radius: 4px; cursor: pointer; }
|
|
||||||
button:hover { background: #005a87; }
|
|
||||||
.status { margin-top: 20px; padding: 10px; border-radius: 4px; }
|
|
||||||
.success { background: #d4edda; color: #155724; }
|
|
||||||
.error { background: #f8d7da; color: #721c24; }
|
|
||||||
.info { background: #d1ecf1; color: #0c5460; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Nostr Relay Authentication</h1>
|
|
||||||
<p>Connect to this Nostr relay using your private key or browser extension.</p>
|
|
||||||
|
|
||||||
<div id="status" class="status info">
|
|
||||||
Ready to authenticate
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<button onclick="loginWithExtension()">Login with Browser Extension (NIP-07)</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="nsec">Or login with private key (nsec):</label>
|
|
||||||
<input type="password" id="nsec" placeholder="nsec1...">
|
|
||||||
<button onclick="loginWithPrivateKey()" style="margin-top: 10px;">Login with Private Key</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<button onclick="logout()" style="background: #dc3545;">Logout</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let currentUser = null;
|
|
||||||
|
|
||||||
function updateStatus(message, type = 'info') {
|
|
||||||
const status = document.getElementById('status');
|
|
||||||
status.textContent = message;
|
|
||||||
status.className = 'status ' + type;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getChallenge() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/challenge');
|
|
||||||
const data = await response.json();
|
|
||||||
return data.challenge;
|
|
||||||
} catch (error) {
|
|
||||||
updateStatus('Failed to get authentication challenge: ' + error.message, 'error');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginWithExtension() {
|
|
||||||
if (!window.nostr) {
|
|
||||||
updateStatus('No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
updateStatus('Connecting to extension...', 'info');
|
|
||||||
|
|
||||||
// Get public key from extension
|
|
||||||
const pubkey = await window.nostr.getPublicKey();
|
|
||||||
|
|
||||||
// Get challenge from server
|
|
||||||
const challenge = await getChallenge();
|
|
||||||
|
|
||||||
// Create authentication event
|
|
||||||
const authEvent = {
|
|
||||||
kind: 22242,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
['relay', window.location.protocol.replace('http', 'ws') + '//' + window.location.host],
|
|
||||||
['challenge', challenge]
|
|
||||||
],
|
|
||||||
content: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sign the event with extension
|
|
||||||
const signedEvent = await window.nostr.signEvent(authEvent);
|
|
||||||
|
|
||||||
// Send to server
|
|
||||||
await authenticate(signedEvent);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
updateStatus('Extension login failed: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginWithPrivateKey() {
|
|
||||||
const nsec = document.getElementById('nsec').value;
|
|
||||||
if (!nsec) {
|
|
||||||
updateStatus('Please enter your private key', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus('Private key login not implemented in this basic interface. Please use a proper Nostr client or extension.', 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authenticate(signedEvent) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(signedEvent)
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
currentUser = result.pubkey;
|
|
||||||
updateStatus('Successfully authenticated as: ' + result.pubkey.slice(0, 16) + '...', 'success');
|
|
||||||
} else {
|
|
||||||
updateStatus('Authentication failed: ' + result.error, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
updateStatus('Authentication request failed: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
currentUser = null;
|
|
||||||
updateStatus('Logged out', 'info');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check authentication status on page load
|
|
||||||
async function checkStatus() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/status');
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.authenticated) {
|
|
||||||
currentUser = data.pubkey;
|
|
||||||
updateStatus('Already authenticated as: ' + data.pubkey.slice(0, 16) + '...', 'success');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore errors for status check
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkStatus();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
w.Write([]byte(html))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAuthChallenge generates and returns an authentication challenge
|
// handleAuthChallenge generates and returns an authentication challenge
|
||||||
@@ -365,3 +209,47 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"authenticated": false}`))
|
w.Write([]byte(`{"authenticated": false}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handlePermissions returns the permission level for a given pubkey
|
||||||
|
func (s *Server) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract pubkey from URL path
|
||||||
|
pubkeyHex := strings.TrimPrefix(r.URL.Path, "/api/permissions/")
|
||||||
|
if pubkeyHex == "" || pubkeyHex == "/" {
|
||||||
|
http.Error(w, "Invalid pubkey", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert hex to binary pubkey
|
||||||
|
pubkey, err := hex.Dec(pubkeyHex)
|
||||||
|
if chk.E(err) {
|
||||||
|
http.Error(w, "Invalid pubkey format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access level using acl registry
|
||||||
|
permission := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
|
||||||
|
|
||||||
|
// Set content type and write JSON response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Format response as proper JSON
|
||||||
|
response := struct {
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
}{
|
||||||
|
Permission: permission,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal and write the response
|
||||||
|
jsonData, err := json.Marshal(response)
|
||||||
|
if chk.E(err) {
|
||||||
|
http.Error(w, "Error generating response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(jsonData)
|
||||||
|
}
|
||||||
|
|||||||
19
app/web.go
Normal file
19
app/web.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web/dist
|
||||||
|
var reactAppFS embed.FS
|
||||||
|
|
||||||
|
// GetReactAppFS returns a http.FileSystem from the embedded React app
|
||||||
|
func GetReactAppFS() http.FileSystem {
|
||||||
|
webDist, err := fs.Sub(reactAppFS, "web/dist")
|
||||||
|
if err != nil {
|
||||||
|
panic("Failed to load embedded web app: " + err.Error())
|
||||||
|
}
|
||||||
|
return http.FS(webDist)
|
||||||
|
}
|
||||||
31
app/web/.gitignore
vendored
Normal file
31
app/web/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Bun
|
||||||
|
.bunfig.toml
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Build directories
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
|
||||||
|
# Cache and logs
|
||||||
|
.cache
|
||||||
|
.temp
|
||||||
|
.log
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
60
app/web/README.md
Normal file
60
app/web/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Orly Web Application
|
||||||
|
|
||||||
|
This is a React web application that uses Bun for building and bundling, and is automatically embedded into the Go binary when built.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh/) - JavaScript runtime and toolkit
|
||||||
|
- Go 1.16+ (for embedding functionality)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
To run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app/web
|
||||||
|
bun install
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
The React application needs to be built before compiling the Go binary to ensure that the embedded files are available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the React application
|
||||||
|
cd app/web
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Build the Go binary from project root
|
||||||
|
cd ../../
|
||||||
|
go build
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. The React application is built to the `app/web/dist` directory
|
||||||
|
2. The Go embed directive in `app/web.go` embeds these files into the binary
|
||||||
|
3. When the server runs, it serves the embedded React app at the root path
|
||||||
|
|
||||||
|
## Build Automation
|
||||||
|
|
||||||
|
You can create a shell script to automate the build process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# build.sh
|
||||||
|
echo "Building React app..."
|
||||||
|
cd app/web
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
echo "Building Go binary..."
|
||||||
|
cd ../../
|
||||||
|
go build
|
||||||
|
|
||||||
|
echo "Build complete!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Make it executable with `chmod +x build.sh` and run with `./build.sh`.
|
||||||
36
app/web/bun.lock
Normal file
36
app/web/bun.lock
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "orly-web",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||||
|
|
||||||
|
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/web/package.json
Normal file
18
app/web/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "orly-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot src/index.jsx",
|
||||||
|
"build": "bun build ./src/index.jsx --outdir ./dist --minify && cp public/index.html dist/",
|
||||||
|
"start": "bun run dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/web/public/index.html
Normal file
12
app/web/public/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Nostr Relay</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
159
app/web/src/App.jsx
Normal file
159
app/web/src/App.jsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [status, setStatus] = useState('Ready to authenticate');
|
||||||
|
const [statusType, setStatusType] = useState('info');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check authentication status on page load
|
||||||
|
checkStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/status');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.authenticated) {
|
||||||
|
setUser(data.pubkey);
|
||||||
|
updateStatus(`Already authenticated as: ${data.pubkey.slice(0, 16)}...`, 'success');
|
||||||
|
|
||||||
|
// Check permissions if authenticated
|
||||||
|
if (data.pubkey) {
|
||||||
|
const permResponse = await fetch(`/api/permissions/${data.pubkey}`);
|
||||||
|
const permData = await permResponse.json();
|
||||||
|
if (permData && permData.permission) {
|
||||||
|
setUser({...data, permission: permData.permission});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors for status check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(message, type = 'info') {
|
||||||
|
setStatus(message);
|
||||||
|
setStatusType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChallenge() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/challenge');
|
||||||
|
const data = await response.json();
|
||||||
|
return data.challenge;
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus('Failed to get authentication challenge: ' + error.message, 'error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithExtension() {
|
||||||
|
if (!window.nostr) {
|
||||||
|
updateStatus('No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateStatus('Connecting to extension...', 'info');
|
||||||
|
|
||||||
|
// Get public key from extension
|
||||||
|
const pubkey = await window.nostr.getPublicKey();
|
||||||
|
|
||||||
|
// Get challenge from server
|
||||||
|
const challenge = await getChallenge();
|
||||||
|
|
||||||
|
// Create authentication event
|
||||||
|
const authEvent = {
|
||||||
|
kind: 22242,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['relay', window.location.protocol.replace('http', 'ws') + '//' + window.location.host],
|
||||||
|
['challenge', challenge]
|
||||||
|
],
|
||||||
|
content: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sign the event with extension
|
||||||
|
const signedEvent = await window.nostr.signEvent(authEvent);
|
||||||
|
|
||||||
|
// Send to server
|
||||||
|
await authenticate(signedEvent);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus('Extension login failed: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(signedEvent) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Check permissions after login
|
||||||
|
const permResponse = await fetch(`/api/permissions/${result.pubkey}`);
|
||||||
|
const permData = await permResponse.json();
|
||||||
|
if (permData && permData.permission) {
|
||||||
|
setUser({pubkey: result.pubkey, permission: permData.permission});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateStatus('Authentication failed: ' + result.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus('Authentication request failed: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
setUser(null);
|
||||||
|
updateStatus('Logged out', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
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"}
|
||||||
|
</div>
|
||||||
|
<button className="logout-button" onClick={logout}>✕</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
11
app/web/src/index.jsx
Normal file
11
app/web/src/index.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
122
app/web/src/styles.css
Normal file
122
app/web/src/styles.css
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: #f9f9f9;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 80px; /* Space for the header panel */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #007cba;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #005a87;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
background: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
background: transparent;
|
||||||
|
color: #6c757d;
|
||||||
|
border: none;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:hover {
|
||||||
|
background: rgba(108, 117, 125, 0.1);
|
||||||
|
color: #343a40;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user