First run of the web UI

This commit is contained in:
2026-11-11 23:25:16 +01:00
parent 9d8b560bac
commit a834cf2576
9 changed files with 262 additions and 77 deletions
+1 -2
View File
@@ -275,8 +275,7 @@ lidateValue`, `Render`, `ResolveConflict`, `DiffSnapshots`, `AppendEvent`), no p
- Validation: performed from the same interface by changing the status to `validated`; the hook appends the `validated` event, and any other status change is refused. A `pending` file can only leave the review queue through a full correction from the dashboard (US-36).
- Deletion: deleting a `documents` record from the same interface first deletes the S3 object at `file_key` (US-24); on S3 failure the deletion is refused and the record stays. The cascade delete of the `document_events` relation removes the history with the record; with versioning the object stays recoverable via its delete marker.
The static pages themselves carry no data and are public by nature; every fetch is authenticated against the PocketBase API and an unauthenticated visitor is redirected to the admin UI (`/_/`). Authentication: single ac
count via PocketBase's standard auth. The configuration is reloaded only on restart; there is no runtime reload.
The static pages themselves carry no data and are public by nature; every fetch is authenticated against the PocketBase API. An unauthenticated visitor is prompted to sign in with the superuser account; the dashboard exchanges those credentials through PocketBase's standard auth endpoint, stores the returned token locally, and retries the blocked request. The API continues to enforce superuser access. The configuration is reloaded only on restart; there is no runtime reload.
### 7.5 Design (Beszel reference)
The dashboard's visual design takes Beszel (https://github.com/henrygd/beszel) as its reference. Beszel is built on shadcn/ui + Tailwind with semantic HSL CSS variables and a light/dark pair; archivist reproduces its palette, token names and layout feel in a single hand-written stylesheet (`pb_public/styles.css`) — no Tailwind, no CSS framework, consistent with the no-build-toolchain constraint of section 7.
+11 -24
View File
@@ -213,8 +213,7 @@ func startApp(image string) error {
if _, err := run("docker", args...); err != nil {
return fmt.Errorf("app container: %w", err)
}
if err := retry(60*time.Second, checkHealth);
err != nil {
if err := retry(60*time.Second, checkHealth); err != nil {
if out, _ := run("docker", "logs", appC); out != "" {
return fmt.Errorf("app never became healthy; logs:\n%s", out)
}
@@ -300,8 +299,7 @@ func firstHexToken(s string) string {
return ""
}
func lineValue(s, prefix string) strin
g {
func lineValue(s, prefix string) string {
for _, line := range strings.Split(s, "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, prefix) {
@@ -378,8 +376,7 @@ func listRecords(t *testing.T, collection, filter, sort string) []map[string]any
q.Set("perPage", "200")
status, out := call(t, http.MethodGet, "/api/collections/"+collection+"/records?"+q.Encode(), nil)
if status != 200 {
t.Fatalf("list %s: status %d: %v
", collection, status, out)
t.Fatalf("list %s: status %d: %v", collection, status, out)
}
items, _ := out["items"].([]any)
recs := make([]map[string]any, 0, len(items))
@@ -456,8 +453,7 @@ func s3Exists(t *testing.T, key string) bool {
return err == nil
}
// --- in-memory test PDFs (no fixtures, no ge
nerator dependency) ----------
// --- in-memory test PDFs (no fixtures, no generator dependency) ----------
// makePDF builds a minimal valid single-page PDF whose page text is the
// given lines. The classification outcome is the proof that the
@@ -507,8 +503,7 @@ func putPDF(t *testing.T, key string, lines ...string) string {
t.Fatalf("PutObject %s: %v", key, err)
}
sum := sha256.Sum256(data)
return hex.EncodeToString(su
m[:])
return hex.EncodeToString(sum[:])
}
// --- the config.jsonc mounted into the image under test -----------------
@@ -572,8 +567,7 @@ func TestE2E(t *testing.T) {
}
}
// PocketBase writes log batches with a small delay.
var fou
nd bool
var found bool
deadline := time.Now().Add(15 * time.Second)
for !found && time.Now().Before(deadline) {
for _, l := range pbLogs(t) {
@@ -631,8 +625,7 @@ nd bool
t.Fatalf("file_key: %v, want %v", doc["file_key"], want)
}
if !s3Exists(t, want) {
t.Fatalf("object %s missing from S3", wan
t)
t.Fatalf("object %s missing from S3", want)
}
if s3Exists(t, "inbox/statement-1.pdf") {
t.Fatal("source object still in inbox/")
@@ -687,8 +680,7 @@ t)
p := eventPayloadOfType(t, eventsOf(t, doc["id"].(string)), "imported")
errs, ok := p["errors"].([]any)
if !ok || len(errs) == 0 {
t.Fatalf("
want errors in the imported event, got %v", p)
t.Fatalf("want errors in the imported event, got %v", p)
}
after, _ := p["after"].(map[string]any)
if after["documentType"] != "Releve de compte" {
@@ -737,8 +729,7 @@ want errors in the imported event, got %v", p)
t.Fatalf("%s must not appear in a field-edit diff: %v", s, before)
}
if _, has := after[s]; has {
t.Fatalf("%s must not appear in a field-edi
t diff: %v", s, after)
t.Fatalf("%s must not appear in a field-edit diff: %v", s, after)
}
}
})
@@ -788,8 +779,7 @@ t diff: %v", s, after)
map[string]any{"event_type": "corrected"}); status < 400 {
t.Fatalf("PATCH on document_events must be refused, got %d", status)
}
if status, _ := call(t, http.MethodDelete, "/api/collections/docum
ent_events/records/"+id, nil); status < 400 {
if status, _ := call(t, http.MethodDelete, "/api/collections/document_events/records/"+id, nil); status < 400 {
t.Fatalf("DELETE on document_events must be refused, got %d", status)
}
if n := len(listRecords(t, "document_events", "", "")); n != len(evs) {
@@ -820,7 +810,6 @@ ent_events/records/"+id, nil); status < 400 {
}
})
// 9. Recovery (US-11): a stuck record - file_key still under inbox/,
// created directly through the API - is completed by the next pass.
t.Run("recovery: a stuck filing is completed by the next pass", func(t *testing.T) {
@@ -840,8 +829,7 @@ ent_events/records/"+id, nil); status < 400 {
t.Fatalf("stuck record creation: %d %v", status, out)
}
poll(t) // the recovery runs at the start of the pass
want := "files/BRED/202
6-02_Releve de compte_Thomas.pdf"
want := "files/BRED/2026-02_Releve de compte_Thomas.pdf"
doc := oneRecord(t, "documents", "(sha256='"+hash+"')")
if doc["file_key"] != want {
t.Fatalf("file_key: %v, want %v", doc["file_key"], want)
@@ -1150,4 +1138,3 @@ ent_events/records/"+id, nil); status < 400 {
}
})
}
+11 -5
View File
@@ -8,11 +8,14 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"ganigeorgiev.com/pocketbase/pocketbase/apis"
"ganigeorgiev.com/pocketbase/pocketbase/core"
"github.com/napnap75/archivist/internal/config"
"github.com/napnap75/archivist/internal/storage"
@@ -23,6 +26,9 @@ import (
// PocketBase auth, but these routes bypass the record API and its hooks.
func BindRoutes(app core.App, cfg *config.Config, s3 *storage.Client) {
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
if !e.Router.HasRoute(http.MethodGet, "/{path...}") {
e.Router.GET("/{path...}", apis.Static(os.DirFS("pb_public"), true))
}
e.Router.GET("/api/archivist/config", func(re *core.RequestEvent) error {
if !re.HasSuperuserAuth() {
return re.ForbiddenError("superusers only", nil)
@@ -139,10 +145,10 @@ func fileName(key string) string {
// dryRunResponse is the single response shape of the two exclusive
// dry-run modes (US-35).
type dryRunResponse struct {
Fields map[string]any "json:"fields""
Template string "json:"template""
Rendered string "json:"rendered""
Errors []string "json:"errors""
Fields map[string]any `json:"fields"`
Template string `json:"template"`
Rendered string `json:"rendered"`
Errors []string `json:"errors"`
}
func handleDryRun(re *core.RequestEvent, cfg *config.Config) error {
@@ -270,7 +276,7 @@ func handleCorrect(re *core.RequestEvent, cfg *config.Config, s3 *storage.Client
if err := tx.Save(record); err != nil {
return err
}
return AppendEvent(tx, record.Id, "corrected", before, after)
return AppendEvent(tx, record.Id, "corrected", before, after, nil)
})
if txErr != nil {
if refiled {
+2 -4
View File
@@ -65,8 +65,7 @@ func LoadEnvConfig() (*EnvConfig, error) {
return c, nil
}
func configPathFromArgs(args []strin
g, defaultPath string) (string, error) {
func configPathFromArgs(args []string, defaultPath string) (string, error) {
var configPath string
flags := pflag.NewFlagSet("archivist", pflag.ContinueOnError)
flags.SetInterspersed(true)
@@ -117,8 +116,7 @@ func main() {
}
// US-01: versioning recommendation, via the PocketBase logger
// (visible in the admin dashboard, Das
hboard > Logs).
// (visible in the admin dashboard, Dashboard > Logs).
if s3.StartupWarning != "" {
app.Logger().Warn(s3.StartupWarning, "bucket", envCfg.S3Bucket)
}
+166 -18
View File
@@ -1,14 +1,13 @@
"use strict";
// Shared helpers of the review-dashboard pages (US-30 to US-36).
// The pages carry no data; everything goes through authenticated API
// calls, the admin session cookie riding along.
// The pages carry no data; everything goes through authenticated API calls.
function redirectLogin() {
window.location.href = "/_/";
}
var memoryToken = "";
var loginPromise = null;
function storedToken() {
if (memoryToken) return memoryToken;
try {
return localStorage.getItem("archivist.token") || "";
} catch (e) {
@@ -16,17 +15,110 @@ function storedToken() {
}
}
// api performs one authenticated call. A stale stored token is dropped
// and the call retried once; an unauthenticated visitor is redirected to
// the admin UI (US-30).
function saveToken(token) {
memoryToken = token;
try {
localStorage.setItem("archivist.token", token);
} catch (e) {}
}
function clearToken() {
memoryToken = "";
try {
localStorage.removeItem("archivist.token");
} catch (e) {}
}
function requestLogin() {
if (loginPromise) return loginPromise;
loginPromise = new Promise(function (resolve) {
var dialog = document.createElement("dialog");
dialog.className = "auth-dialog";
dialog.setAttribute("aria-labelledby", "auth-title");
dialog.innerHTML = '<div class="auth-card">' +
'<h2 id="auth-title">Connexion requise</h2>' +
'<p>Connectez-vous avec un compte superutilisateur.</p>' +
'<form class="auth-form">' +
'<label>Adresse e-mail<input name="identity" type="email" autocomplete="username" required autofocus></label>' +
'<label>Mot de passe<input name="password" type="password" autocomplete="current-password" required></label>' +
'<p class="auth-error" role="alert" aria-live="polite"></p>' +
'<div class="auth-actions"><a href="/_/">Administration PocketBase</a>' +
'<button class="btn primary" type="submit">Se connecter</button></div>' +
'</form></div>';
var form = dialog.querySelector("form");
var error = dialog.querySelector(".auth-error");
var submit = form.querySelector("button[type=submit]");
form.addEventListener("submit", async function (event) {
event.preventDefault();
error.textContent = "";
submit.disabled = true;
try {
var result = await rawFetch("POST", "/api/collections/_superusers/auth-with-password", {
identity: form.elements.identity.value,
password: form.elements.password.value
}, "");
var token = result.data && result.data.token;
if (result.status !== 200 || !token) {
error.textContent = result.data && result.data.message || "Identifiants invalides.";
submit.disabled = false;
return;
}
saveToken(token);
dialog.close();
dialog.remove();
resolve(token);
} catch (err) {
error.textContent = "Connexion impossible. Vérifiez le réseau puis réessayez.";
submit.disabled = false;
}
});
dialog.addEventListener("cancel", function (event) {
event.preventDefault();
});
document.body.appendChild(dialog);
dialog.showModal();
});
var pending = loginPromise;
return pending.finally(function () {
loginPromise = null;
});
}
// api retries the blocked request after sign-in. A stale token is cleared
// first; the server still enforces superuser access on every protected route.
async function api(method, path, body) {
var res = await rawFetch(method, path, body, storedToken());
if (res.status === 401 && storedToken()) {
try { localStorage.removeItem("archivist.token"); } catch (e) {}
res = await rawFetch(method, path, body, "");
var token = storedToken();
var res = await rawFetch(method, path, body, token);
if (res.status === 401 && token) {
clearToken();
token = "";
res = await rawFetch(method, path, body, token);
}
if (res.status === 401 || (res.status === 403 && path.indexOf("/api/archivist/") === 0)) {
redirectLogin();
token = await requestLogin();
res = await rawFetch(method, path, body, token);
if (res.status === 401) clearToken();
}
return res;
}
// An iframe cannot attach the dashboard's Authorization header, so protected
// binary resources use fetch and are assigned to the frame as object URLs.
async function apiBlob(path) {
var token = storedToken();
var res = await rawBlob(path, token);
if (res.status === 401 && token) {
clearToken();
token = "";
res = await rawBlob(path, token);
}
if (res.status === 401 || (res.status === 403 && path.indexOf("/api/archivist/") === 0)) {
token = await requestLogin();
res = await rawBlob(path, token);
if (res.status === 401) clearToken();
}
return res;
}
@@ -46,6 +138,12 @@ async function rawFetch(method, path, body, token) {
return { status: res.status, data: data };
}
async function rawBlob(path, token) {
var opts = { method: "GET", headers: {} };
if (token) opts.headers["Authorization"] = token;
return fetch(path, opts);
}
function errMsg(res) {
if (res && res.data && res.data.message) return res.data.message;
if (res && res.data) {
@@ -94,6 +192,60 @@ function addOption(sel, value, label) {
sel.appendChild(o);
}
function buildEnumControl(meta, vocab, value) {
var current = value === null || value === undefined ? "" : String(value);
var wrapper = document.createElement("div");
wrapper.className = "enum-control";
wrapper.dataset.field = meta.name;
var select = document.createElement("select");
addOption(select, "", "—");
(vocab || []).forEach(function (item) { addOption(select, item, item); });
if (current && !(vocab || []).includes(current)) addOption(select, current, current);
addOption(select, "__archivist_custom_value__", "Ajouter une valeur…");
var custom = document.createElement("input");
custom.type = "text";
custom.placeholder = "Saisir une valeur";
custom.setAttribute("aria-label", "Nouvelle valeur pour " + meta.name);
custom.hidden = true;
select.value = current;
select.addEventListener("change", function () {
var adding = select.value === "__archivist_custom_value__";
custom.hidden = !adding;
if (adding) {
custom.value = "";
custom.focus();
} else {
custom.value = "";
}
});
Object.defineProperty(wrapper, "value", {
get: function () { return custom.hidden ? select.value : custom.value; },
set: function (next) {
var nextValue = next === null || next === undefined ? "" : String(next);
var optionExists = Array.from(select.options).some(function (option) {
return option.value === nextValue;
});
if (optionExists) {
select.value = nextValue;
custom.value = "";
custom.hidden = true;
} else {
select.value = "__archivist_custom_value__";
custom.value = nextValue;
custom.hidden = false;
}
}
});
wrapper.appendChild(select);
wrapper.appendChild(custom);
return wrapper;
}
// buildValueControl builds the typed control of one field (US-33 bulk
// edit, US-34 form, US-32 filters).
function buildValueControl(meta, vocab, value) {
@@ -119,11 +271,7 @@ function buildValueControl(meta, vocab, value) {
el.value = value === true ? "true" : (value === false ? "false" : "");
break;
case "enum":
el = document.createElement("select");
addOption(el, "", "—");
(vocab || []).forEach(function (v) { addOption(el, v, v); });
el.value = s;
break;
return buildEnumControl(meta, vocab, value);
default:
el = document.createElement("input");
el.type = "text";
+27 -2
View File
@@ -27,6 +27,7 @@ async function init() {
initTheme("themeBtn");
renderHead();
loadPdf();
var evs = await loadEvents();
renderImportErrors(evs);
renderHistory(evs);
@@ -38,7 +39,7 @@ async function init() {
}
async function loadEvents() {
var q = "/api/collections/document_events/records?perPage=200&sort=timestamp&filter=" +
var q = "/api/collections/document_events/records?perPage=200&sort=-timestamp&filter=" +
encodeURIComponent("(document='" + docId + "')");
var r = await api("GET", q);
return (r.status === 200 && r.data.items) ? r.data.items : [];
@@ -50,7 +51,31 @@ function renderHead() {
var st = $("docstatus");
st.textContent = record.status || "";
st.className = "badge st-" + record.status;
$("pdf").src = "/api/archivist/documents/" + docId + "/pdf";
}
async function loadPdf() {
var iframe = $("pdf");
var error = $("pdferror");
try {
var response = await apiBlob("/api/archivist/documents/" + docId + "/pdf");
if (!response.ok) {
var data = null;
try { data = await response.json(); } catch (e) {}
error.textContent = "Aperçu indisponible : " + (data && data.message || "HTTP " + response.status);
error.hidden = false;
iframe.hidden = true;
return;
}
var objectUrl = URL.createObjectURL(await response.blob());
iframe.src = objectUrl;
window.addEventListener("pagehide", function () { URL.revokeObjectURL(objectUrl); }, { once: true });
} catch (e) {
var reason = e && e.message ? e.message : String(e);
console.error("PDF preview failed:", e);
error.textContent = "Impossible de charger l'aperçu PDF : " + reason;
error.hidden = false;
iframe.hidden = true;
}
}
function payloadOf(ev) {
+1
View File
@@ -25,6 +25,7 @@
<div class="detailgrid">
<section class="card pdfpane">
<iframe id="pdf" title="Aperçu du document"></iframe>
<div id="pdferror" class="errbox" hidden></div>
</section>
<section>
<div id="importerrors" class="errbox" hidden></div>
+21
View File
@@ -161,12 +161,15 @@ tbody tr:hover { background: var(--accent); }
.pdfpane { padding: 0; overflow: hidden; height: 75vh; }
.pdfpane iframe { width: 100%; height: 100%; border: 0; display: block; background: var(--muted); }
#pdf[hidden] { display: none; }
#pdferror { margin: 1rem; }
.formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem 1rem; }
@media (max-width: 700px) { .formgrid { grid-template-columns: 1fr; } }
.fieldrow { display: flex; flex-direction: column; gap: .25rem; }
.fieldrow > label { font-size: 12px; color: var(--muted-foreground); }
.fieldrow input, .fieldrow select { width: 100%; }
.enum-control { display: grid; gap: .35rem; }
.mandatory { color: var(--destructive); }
.proposal { font-size: 12px; color: var(--muted-foreground); display: flex; gap: .5rem; align-items: center; }
.proposal[hidden] { display: none; }
@@ -182,6 +185,24 @@ tbody tr:hover { background: var(--accent); }
.errbox[hidden] { display: none; }
.errbox ul { margin: .25rem 0 0 1rem; padding: 0; }
.auth-dialog {
width: min(28rem, calc(100vw - 2rem));
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0;
background: var(--card);
color: var(--card-foreground);
}
.auth-dialog::backdrop { background: hsl(220 8% 10% / .52); }
.auth-card { padding: 1.25rem; }
.auth-card h2 { margin: 0; font-size: 1.1rem; }
.auth-card > p { margin: .35rem 0 1rem; color: var(--muted-foreground); }
.auth-form { display: grid; gap: .75rem; }
.auth-form label { display: grid; gap: .25rem; color: var(--muted-foreground); }
.auth-form input { width: 100%; }
.auth-error { min-height: 1.3em; margin: 0; color: var(--destructive); }
.auth-actions { display: flex; justify-content: space-between; align-items: center; gap: .75rem; flex-wrap: wrap; }
.targetname { font-size: 13px; color: var(--muted-foreground); margin-bottom: .75rem; word-break: break-all; }
.actions { display: flex; gap: .75rem; margin-top: 1rem; flex-wrap: wrap; }
+1 -1
View File
@@ -253,7 +253,7 @@ Acceptance criteria:
- Given the image is built and running, when I open `http://<server>:8090/`, then the dashboard pages are served from `pb_public/` — plain HTML/CSS/JS, no build toolchain, dependencies vendored locally, nothing loaded from a CDN.
- Given a page is opened, when it renders, then it carries no document data by itself; everything is loaded through authenticated API calls.
- Given an unauthenticated visitor, when a page requests data, then the API refuses it and the page redirects to the admin UI (`/_/`).
- Given an unauthenticated visitor, when a page requests data, then the page prompts for superuser credentials, authenticates through PocketBase, and retries the request; the API refuses access until authentication succeeds.
- Given the pages, when they are styled, then they follow the Beszel design reference (section 7.5): same semantic HSL tokens, light/dark pair switched by a class on `<html>`, cards, hairline borders, sticky table headers — reproduced in one hand-written stylesheet, no Tailwind or CSS framework.
### US-31 — Expose the configuration metadata