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"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lol.mleku.dev/chk"
|
"lol.mleku.dev/chk"
|
||||||
"next.orly.dev/app/config"
|
"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/status", s.handleAuthStatus)
|
||||||
s.mux.HandleFunc("/api/auth/logout", s.handleAuthLogout)
|
s.mux.HandleFunc("/api/auth/logout", s.handleAuthLogout)
|
||||||
s.mux.HandleFunc("/api/permissions/", s.handlePermissions)
|
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
|
// 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)
|
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
|
// Login view layout measurements
|
||||||
const titleRef = useRef(null);
|
const titleRef = useRef(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
const [loginPadding, setLoginPadding] = useState(16); // default fallback padding in px
|
const [loginPadding, setLoginPadding] = useState(16); // default fallback padding in px
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -339,6 +340,34 @@ function App() {
|
|||||||
updateStatus('Logged out', 'info');
|
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
|
// Prevent UI flash: wait until we checked auth status
|
||||||
if (checkingAuth) {
|
if (checkingAuth) {
|
||||||
return null;
|
return null;
|
||||||
@@ -347,8 +376,9 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{user?.permission ? (
|
{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="flex items-center h-full w-full box-border">
|
||||||
<div className="relative overflow-hidden flex flex-grow items-center justify-start h-full">
|
<div className="relative overflow-hidden flex flex-grow items-center justify-start h-full">
|
||||||
{profileData?.banner && (
|
{profileData?.banner && (
|
||||||
@@ -372,6 +402,74 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
// Not logged in view - shows the login form
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user