Refine login view styling and update authentication text.
- Updated `App.jsx` to improve layout with centered flexbox and dynamic width. - Adjusted login text for better clarity: "Authenticate" replaces "Connect".
This commit is contained in:
129
app/server.go
129
app/server.go
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/app/config"
|
||||
@@ -156,6 +157,11 @@ func (s *Server) UserInterface() {
|
||||
s.mux.HandleFunc("/api/auth/status", s.handleAuthStatus)
|
||||
s.mux.HandleFunc("/api/auth/logout", s.handleAuthLogout)
|
||||
s.mux.HandleFunc("/api/permissions/", s.handlePermissions)
|
||||
// Export endpoints
|
||||
s.mux.HandleFunc("/api/export", s.handleExport)
|
||||
s.mux.HandleFunc("/api/export/mine", s.handleExportMine)
|
||||
// Import endpoint (admin only)
|
||||
s.mux.HandleFunc("/api/import", s.handleImport)
|
||||
}
|
||||
|
||||
// handleLoginInterface serves the main user interface for login
|
||||
@@ -356,3 +362,126 @@ func (s *Server) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
// handleExport streams all events as JSONL (NDJSON). Admins only.
|
||||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
requesterPubHex := c.Value
|
||||
requesterPub, err := hex.Dec(requesterPubHex)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Check permissions
|
||||
if acl.Registry.GetAccessLevel(requesterPub, r.RemoteAddr) != "admin" {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional filtering by pubkey(s)
|
||||
var pks [][]byte
|
||||
q := r.URL.Query()
|
||||
for _, pkHex := range q["pubkey"] {
|
||||
if pkHex == "" {
|
||||
continue
|
||||
}
|
||||
if pk, err := hex.Dec(pkHex); !chk.E(err) {
|
||||
pks = append(pks, pk)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
filename := "events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl"
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
|
||||
// Stream export
|
||||
s.D.Export(s.Ctx, w, pks...)
|
||||
}
|
||||
|
||||
|
||||
// handleExportMine streams only the authenticated user's events as JSONL (NDJSON).
|
||||
func (s *Server) handleExportMine(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
pubkey, err := hex.Dec(c.Value)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
filename := "my-events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl"
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
|
||||
// Stream export for this user's pubkey only
|
||||
s.D.Export(s.Ctx, w, pubkey)
|
||||
}
|
||||
|
||||
// handleImport receives a JSONL/NDJSON file or body and enqueues an async import. Admins only.
|
||||
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
requesterPub, err := hex.Dec(c.Value)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Admins only
|
||||
if acl.Registry.GetAccessLevel(requesterPub, r.RemoteAddr) != "admin" {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(32 << 20); chk.E(err) { // 32MB memory, rest to temp files
|
||||
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
s.D.Import(file)
|
||||
} else {
|
||||
if r.Body == nil {
|
||||
http.Error(w, "Empty request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.D.Import(r.Body)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
w.Write([]byte(`{"success": true, "message": "Import started"}`))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ function App() {
|
||||
|
||||
// Login view layout measurements
|
||||
const titleRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const [loginPadding, setLoginPadding] = useState(16); // default fallback padding in px
|
||||
|
||||
useEffect(() => {
|
||||
@@ -339,6 +340,34 @@ function App() {
|
||||
updateStatus('Logged out', 'info');
|
||||
}
|
||||
|
||||
function handleImportButton() {
|
||||
try {
|
||||
fileInputRef?.current?.click();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handleImportChange(e) {
|
||||
const file = e?.target?.files && e.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
updateStatus('Uploading import file...', 'info');
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('/api/import', { method: 'POST', body: fd });
|
||||
if (res.ok) {
|
||||
updateStatus('Import started. Processing will continue in the background.', 'success');
|
||||
} else {
|
||||
const txt = await res.text();
|
||||
updateStatus('Import failed: ' + txt, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
updateStatus('Import failed: ' + (err?.message || String(err)), 'error');
|
||||
} finally {
|
||||
// reset input so selecting the same file again works
|
||||
if (e && e.target) e.target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent UI flash: wait until we checked auth status
|
||||
if (checkingAuth) {
|
||||
return null;
|
||||
@@ -347,8 +376,9 @@ function App() {
|
||||
return (
|
||||
<>
|
||||
{user?.permission ? (
|
||||
// Logged in view with user profile
|
||||
<div className="sticky top-0 left-0 w-full bg-gray-100 z-50 h-16 flex items-center overflow-hidden">
|
||||
<>
|
||||
{/* Logged in view with user profile */}
|
||||
<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="relative overflow-hidden flex flex-grow items-center justify-start h-full">
|
||||
{profileData?.banner && (
|
||||
@@ -372,6 +402,74 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Hidden file input for import (admin) */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImportChange}
|
||||
accept=".json,.jsonl,text/plain,application/x-ndjson,application/json"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div className="p-2 w-full bg-white border-b border-gray-200">
|
||||
<div className="text-lg font-bold flex items-center">Welcome</div>
|
||||
<p>here you can configure all the things</p>
|
||||
</div>
|
||||
|
||||
{/* Export only my events */}
|
||||
<div className="p-2 bg-white rounded w-full">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="pr-2 w-full">
|
||||
<div className="text-base font-bold mb-1">Export My Events</div>
|
||||
<p className="text-sm w-full text-gray-700">Download your own events as line-delimited JSON (JSONL/NDJSON). Only events you authored will be included.</p>
|
||||
</div>
|
||||
<button
|
||||
className="bg-gray-100 text-gray-500 border-0 text-2xl cursor-pointer flex items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent hover:text-gray-800"
|
||||
onClick={() => { window.location.href = '/api/export/mine'; }}
|
||||
aria-label="Download my events as JSONL"
|
||||
title="Download my events"
|
||||
>
|
||||
⤓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.permission === "admin" && (
|
||||
<>
|
||||
<div className="p-2 w-full rounded">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="pr-2 w-full">
|
||||
<div className="text-base font-bold mb-1">Export All Events (admin)</div>
|
||||
<p className="text-sm text-gray-700">Download all stored events as line-delimited JSON (JSONL/NDJSON). This may take a while on large databases.</p>
|
||||
</div>
|
||||
<button
|
||||
className="bg-gray-100 text-gray-500 border-0 text-2xl cursor-pointer flex items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent hover:text-gray-800"
|
||||
onClick={() => { window.location.href = '/api/export'; }}
|
||||
aria-label="Download all events as JSONL"
|
||||
title="Download all events"
|
||||
>
|
||||
⤓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 w-full rounded">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="pr-2 w-full">
|
||||
<div className="text-base font-bold mb-1">Import Events (admin)</div>
|
||||
<p className="text-sm text-gray-700">Upload events in line-delimited JSON (JSONL/NDJSON) to import into the database.</p>
|
||||
</div>
|
||||
<button
|
||||
className="bg-gray-100 text-gray-500 border-0 text-2xl cursor-pointer flex items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent hover:text-gray-800"
|
||||
onClick={handleImportButton}
|
||||
aria-label="Import events from JSONL"
|
||||
title="Import events"
|
||||
>
|
||||
↥
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Not logged in view - shows the login form
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
|
||||
Reference in New Issue
Block a user