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:
2025-09-20 19:03:25 +01:00
parent 9c85dca598
commit 0b69ea6d80
11 changed files with 522 additions and 162 deletions

31
app/web/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}