From ecee58e33357fa4ea72e5cde6ada72f20b589f72 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:16:32 +0200 Subject: [PATCH 01/12] feat(config): add webserver base URL and upload limit defaults --- src/agiladmin/config.clj | 14 ++++++- test/agiladmin/config_test.clj | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/agiladmin/config.clj b/src/agiladmin/config.clj index 257d3a0..dc3cf55 100644 --- a/src/agiladmin/config.clj +++ b/src/agiladmin/config.clj @@ -36,6 +36,9 @@ (s/optional-key :projects) [s/Str] (s/optional-key :webserver) {(s/optional-key :port) s/Num (s/optional-key :host) s/Str + (s/optional-key :base-host) s/Str + (s/optional-key :base-path) s/Str + (s/optional-key :upload-max-size) s/Num (s/optional-key :anti-forgery) s/Bool (s/optional-key :ssl-redirect) s/Bool} (s/optional-key :source) {:git s/Str @@ -82,7 +85,10 @@ :ssh-key "id_rsa" :path "budgets/"} :webserver - {:anti-forgery false + {:base-host "" + :base-path "/" + :upload-max-size 500000 + :anti-forgery false :ssl-redirect false}}) (def project-defaults {}) @@ -273,6 +279,12 @@ (defn load-config [name default] (log/info (str "Loading configuration: " name)) (let [conf (config-read name default) + conf (if (f/failed? conf) + conf + (let [app-key (keyword (:appname conf))] + (update-in conf + [app-key :webserver] + #(merge (:webserver default-settings) %)))) loaded-paths (->> (:paths conf) (filter #(.exists (io/as-file %))) vec) diff --git a/test/agiladmin/config_test.clj b/test/agiladmin/config_test.clj index b570295..480e8cb 100644 --- a/test/agiladmin/config_test.clj +++ b/test/agiladmin/config_test.clj @@ -150,6 +150,43 @@ (:paths conf) => ["doc/agiladmin.pocketbase.yaml"] (get-in conf [:agiladmin :pocketbase :base-url]) => "http://127.0.0.1:8090")) +(fact "Application config loader fills webserver defaults for legacy configs" + (let [path "/tmp/agiladmin-legacy-webserver.yaml" + _ (spit path + (str "appname: agiladmin\n\n" + "agiladmin:\n" + " budgets:\n" + " git: ssh://git@example.org/admin-budgets\n" + " ssh-key: id_rsa\n" + " path: budgets/\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => false + (get-in conf [:agiladmin :webserver :base-host]) => "" + (get-in conf [:agiladmin :webserver :base-path]) => "/" + (get-in conf [:agiladmin :webserver :upload-max-size]) => 500000)) + +(fact "Application config loader preserves explicit webserver base values" + (let [path "/tmp/agiladmin-webserver-explicit.yaml" + _ (spit path + (str "appname: agiladmin\n\n" + "agiladmin:\n" + " budgets:\n" + " git: ssh://git@example.org/admin-budgets\n" + " ssh-key: id_rsa\n" + " path: budgets/\n" + " webserver:\n" + " host: 127.0.0.1\n" + " port: 8088\n" + " base-host: https://admin.example.org\n" + " base-path: /agiladmin\n" + " upload-max-size: 750000\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => false + (get-in conf [:agiladmin :webserver :host]) => "127.0.0.1" + (get-in conf [:agiladmin :webserver :base-host]) => "https://admin.example.org" + (get-in conf [:agiladmin :webserver :base-path]) => "/agiladmin" + (get-in conf [:agiladmin :webserver :upload-max-size]) => 750000)) + (fact "Application config loader reports an explicit missing file" (let [conf (conf/load-config "/tmp/does-not-exist-agiladmin.yaml" conf/default-settings)] (f/failed? conf) => true @@ -235,3 +272,34 @@ (f/message conf) => (contains "Invalid configuration at") (f/message conf) => (contains "test-resources/extra-keys-config.yaml") (f/message conf) => (contains "disallowed-key"))) + +(fact "Application config loader rejects non-string webserver base keys" + (let [path "/tmp/agiladmin-invalid-webserver-base.yaml" + _ (spit path + (str "appname: agiladmin\n\n" + "agiladmin:\n" + " budgets:\n" + " git: ssh://git@example.org/admin-budgets\n" + " ssh-key: id_rsa\n" + " path: budgets/\n" + " webserver:\n" + " base-host: 42\n" + " base-path: true\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => true + (f/message conf) => (contains ":base-host"))) + +(fact "Application config loader rejects non-numeric upload-max-size" + (let [path "/tmp/agiladmin-invalid-upload-size.yaml" + _ (spit path + (str "appname: agiladmin\n\n" + "agiladmin:\n" + " budgets:\n" + " git: ssh://git@example.org/admin-budgets\n" + " ssh-key: id_rsa\n" + " path: budgets/\n" + " webserver:\n" + " upload-max-size: large\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => true + (f/message conf) => (contains ":upload-max-size"))) From 67d38c9c26b704be5503f0b09ec0f9b45e23106d Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:17:45 +0200 Subject: [PATCH 02/12] feat(web): add canonical base-path and public URL helpers --- src/agiladmin/webpage.clj | 42 +++++++++++++++++++++++++++++++++ test/agiladmin/webpage_test.clj | 21 +++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/agiladmin/webpage.clj b/src/agiladmin/webpage.clj index b0d2cec..f72b3d4 100644 --- a/src/agiladmin/webpage.clj +++ b/src/agiladmin/webpage.clj @@ -18,6 +18,7 @@ (ns agiladmin.webpage (:require [clojure.java.io :as io] + [clojure.string :as str] [clojure.data.json :as json] [clojure.data.csv :as csv] [yaml.core :as yaml] @@ -44,6 +45,47 @@ (declare render-fragment) (declare filterable-button-list) +(defn base-path + "Return the normalized public mount prefix for browser-facing paths." + [config] + (let [raw (or (get-in config [:agiladmin :webserver :base-path]) "/") + trimmed (str/trim raw) + cleaned (-> trimmed + (str/replace #"^/+" "") + (str/replace #"/+$" ""))] + (if (str/blank? cleaned) + "/" + (str "/" cleaned)))) + +(defn path + "Join the public base path with an app-local route for browser use." + [config route] + (let [prefix (base-path config) + normalized-route (-> (or route "") + (str/trim) + (str/replace #"^/+" "")) + route-part (if (str/blank? normalized-route) + "" + (str "/" normalized-route))] + (if (= "/" prefix) + (if (str/blank? route-part) "/" route-part) + (str prefix route-part)))) + +(defn asset-path + "Return a browser path for static assets under the configured base path." + [config asset] + (path config asset)) + +(defn public-url + "Build a public absolute URL when a base host is configured." + [config route] + (let [host (-> (or (get-in config [:agiladmin :webserver :base-host]) "") + str/trim + (str/replace #"/+$" ""))] + (if (str/blank? host) + (path config route) + (str host (path config route))))) + (defn icon ([name] (icon name "")) ([name extra-class] diff --git a/test/agiladmin/webpage_test.clj b/test/agiladmin/webpage_test.clj index d9f3b35..010ac07 100644 --- a/test/agiladmin/webpage_test.clj +++ b/test/agiladmin/webpage_test.clj @@ -4,6 +4,27 @@ [hiccup.core :as hiccup] [midje.sweet :refer :all])) +(fact "Base path helper normalizes root and nested prefixes" + (webpage/base-path {:agiladmin {:webserver {:base-path "/"}}}) => "/" + (webpage/base-path {:agiladmin {:webserver {:base-path "/agiladmin"}}}) => "/agiladmin" + (webpage/base-path {:agiladmin {:webserver {:base-path "agiladmin/"}}}) => "/agiladmin" + (webpage/base-path {:agiladmin {:webserver {:base-path ""}}}) => "/") + +(fact "Path helper applies base path to app routes" + (webpage/path {:agiladmin {:webserver {:base-path "/"}}} "/timesheets") => "/timesheets" + (webpage/path {:agiladmin {:webserver {:base-path "/agiladmin"}}} "/timesheets") => "/agiladmin/timesheets" + (webpage/path {:agiladmin {:webserver {:base-path "agiladmin/"}}} "timesheets") => "/agiladmin/timesheets") + +(fact "Public URL helper includes host when present" + (webpage/public-url {:agiladmin {:webserver {:base-host "https://admin.example.org" + :base-path "/agiladmin"}}} + "/timesheets") + => "https://admin.example.org/agiladmin/timesheets" + (webpage/public-url {:agiladmin {:webserver {:base-host "" + :base-path "/agiladmin"}}} + "/timesheets") + => "/agiladmin/timesheets") + (fact "Button keeps a single hidden field intact" (let [html (hiccup/html (webpage/button "/person" "Open" From 76f273f0702f018870c1a66abe3403c39fff4be4 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:21:38 +0200 Subject: [PATCH 03/12] feat(web): apply base path to shared UI links and assets --- src/agiladmin/handlers.clj | 8 +- src/agiladmin/session.clj | 2 +- src/agiladmin/view_auth.clj | 10 +- src/agiladmin/webpage.clj | 239 +++++++++++++++++--------------- test/agiladmin/webpage_test.clj | 33 ++++- 5 files changed, 172 insertions(+), 120 deletions(-) diff --git a/src/agiladmin/handlers.clj b/src/agiladmin/handlers.clj index 9b5be63..2573ca7 100644 --- a/src/agiladmin/handlers.clj +++ b/src/agiladmin/handlers.clj @@ -58,10 +58,10 @@ (GET "/" request (if (get-in request [:session :auth]) {:status 302 - :headers {"Location" "/persons/list"} + :headers {"Location" (web/path @ring/config "/persons/list")} :body ""} {:status 302 - :headers {"Location" "/login"} + :headers {"Location" (web/path @ring/config "/login")} :body ""})) (GET "/projects/list" request @@ -105,7 +105,7 @@ (web/render [:div (web/render-error "Unauthorized access.") ;; TODO: (f/message e)) reports all config?! - web/login-form])))) + (web/login-form)])))) ;; (web/render account [:div ;; (web/render-yaml account) ;; (web/render-yaml config)]))) @@ -205,7 +205,7 @@ (web/render acct [:div {:class "space-y-4"} - [:form {:action "/config/edit" + [:form {:action (web/path conf "/config/edit") :method "post" :class "space-y-4"} [:h1 "Configuration editor"] diff --git a/src/agiladmin/session.clj b/src/agiladmin/session.clj index b41d7a1..6a048f0 100644 --- a/src/agiladmin/session.clj +++ b/src/agiladmin/session.clj @@ -110,7 +110,7 @@ (web/render [:div (web/render-error error) - web/login-form]))) + (web/login-form)]))) (defn check [request fun] (f/attempt-all diff --git a/src/agiladmin/view_auth.clj b/src/agiladmin/view_auth.clj index 8aebd15..d5cb4c9 100644 --- a/src/agiladmin/view_auth.clj +++ b/src/agiladmin/view_auth.clj @@ -39,9 +39,11 @@ [:h1 {:class "card-title text-3xl"} (str "Already logged in with account: " (:email acct))] [:div {:class "card-actions"} - [:a {:class "btn btn-primary" :href "/logout"} "Logout"]]]]) + [:a {:class "btn btn-primary" + :href (web/path @ring/config "/logout")} + "Logout"]]]]) (f/when-failed [e] - (web/render web/login-form)))) + (web/render (web/login-form))))) (defn login-post [request] (f/attempt-all @@ -62,7 +64,7 @@ [:h1 {:class "card-title text-3xl"} "Logged in: " username]]])) (assoc session :status 302 - :headers {"Location" "/persons/list"} + :headers {"Location" (web/path @ring/config "/persons/list")} :body ""))) (f/when-failed [e] (web/render-error-page @@ -73,7 +75,7 @@ (web/render [:h1 "Logged out."]))) (defn signup-get [request] - (web/render web/signup-form)) + (web/render (web/signup-form))) (defn signup-post [request] (f/attempt-all diff --git a/src/agiladmin/webpage.clj b/src/agiladmin/webpage.clj index f72b3d4..8c2975d 100644 --- a/src/agiladmin/webpage.clj +++ b/src/agiladmin/webpage.clj @@ -86,6 +86,25 @@ (path config route) (str host (path config route))))) +(defn- current-config + [] + (or @ring/config {})) + +(defn button-for + "Render a POST form button and route its action through the configured base path." + [config url text field type] + (let [fields (cond + (nil? field) [] + (and (seq? field) (every? vector? field)) field + :else [field]) + form-class (str "inline-flex max-w-full" + (when (re-find #"(?:^|\s)w-full(?:\s|$)" type) + " w-full"))] + (apply hf/form-to + {:class form-class} [:post (path config url)] + (concat fields + [(hf/submit-button {:class type} text)])))) + (defn icon ([name] (icon name "")) ([name extra-class] @@ -149,21 +168,9 @@ (defn button ([url text] (button url text [:p])) - ([url text field] (button url text field "btn btn-primary")) - ([url text field type] - (let [fields (cond - (nil? field) [] - (and (seq? field) (every? vector? field)) field - :else [field]) - form-class (str "inline-flex max-w-full" - (when (re-find #"(?:^|\s)w-full(?:\s|$)" type) - " w-full"))] - (apply hf/form-to - {:class form-class} [:post url] - (concat fields - [(hf/submit-button {:class type} text)]))))) + (button-for (current-config) url text field type))) (defn button-prev-year [year person] [:div {:class "w-full lg:w-1/4"} @@ -257,22 +264,26 @@ (manager-role? account))) (defn- account-nav-links - [account] - (cond-> [] - (project-access? account) - (conj {:href "/persons/list" :icon :user-circle :label "Personnel"} - {:href "/projects/list" :icon :paper-airplane :label "Projects"}) + ([account] + (account-nav-links (current-config) account)) + ([config account] + (cond-> [] + (project-access? account) + (conj {:href (path config "/persons/list") :icon :user-circle :label "Personnel"} + {:href (path config "/projects/list") :icon :paper-airplane :label "Projects"}) - (admin-role? account) - (conj {:href "/reload" :icon :arrow-path :label "Reload"} - {:href "/config" :icon :document-text :label "Configuration"}) + (admin-role? account) + (conj {:href (path config "/reload") :icon :arrow-path :label "Reload"} + {:href (path config "/config") :icon :document-text :label "Configuration"}) - true - (conj {:href "/logout" :icon :user-circle :label "Logout"}))) + true + (conj {:href (path config "/logout") :icon :user-circle :label "Logout"})))) (defn- account-home-href - [account] - "/persons/list") + ([_account] + (account-home-href (current-config) _account)) + ([config _account] + (path config "/persons/list"))) (defn- theme-toggle [] @@ -286,14 +297,14 @@ [:span {:class "swap-on"} (icon :sun "h-5 w-5")]]) (defn- navbar - [toggle-id links home-href] + [config toggle-id links home-href] [:nav {:class "sticky top-0 z-40 border-b border-base-300 bg-base-100/90 shadow-sm backdrop-blur"} [:div {:class "mx-auto flex min-h-0 w-full max-w-screen-2xl items-center justify-between px-4 py-2 md:hidden md:px-6"} [:a {:class "flex items-center gap-2 no-underline" :href home-href} [:span {:class "flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-base-300/50 bg-base-100 shadow-sm"} - [:img {:src "/static/img/dyne-icon-black.svg" + [:img {:src (asset-path config "/static/img/dyne-icon-black.svg") :class "h-[1.2rem] w-[1.2rem] object-contain" :alt "Dyne icon" :data-theme-invert "true"}]] @@ -313,7 +324,7 @@ [:a {:class "flex items-center gap-3 no-underline" :href home-href} [:span {:class "flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-base-300/50 bg-base-100 shadow-sm"} - [:img {:src "/static/img/dyne-icon-black.svg" + [:img {:src (asset-path config "/static/img/dyne-icon-black.svg") :class "h-[1.2rem] w-[1.2rem] object-contain" :alt "Dyne icon" :data-theme-invert "true"}]] @@ -345,7 +356,7 @@ :data-theme-light "nord" :data-theme-dark "dim" :class "min-h-screen bg-base-200 text-base-content"} - navbar-guest + (navbar-guest) [:main {:class "mx-auto w-full max-w-screen-2xl px-4 pb-12 pt-6 md:px-6"} body] (render-footer) [:div {:data-page-loading "true" @@ -364,7 +375,7 @@ :data-theme-dark "dim" :class "min-h-screen bg-base-200 text-base-content"} (if (empty? account) - navbar-guest + (navbar-guest) (navbar-account account)) [:main {:class "mx-auto w-full max-w-screen-2xl px-4 pb-12 pt-6 md:px-6"} body] (render-footer) @@ -394,12 +405,13 @@ (defn render-head - ([] (render-head - "Agiladmin" ;; default title - "Agiladmin" - "https://agiladmin.dyne.org")) ;; default desc - - ([title _desc _url] + ([] (render-head (current-config) + "Agiladmin" ;; default title + "Agiladmin" + "https://agiladmin.dyne.org")) ;; default desc + ([config] + (render-head config "Agiladmin" "Agiladmin" "https://agiladmin.dyne.org")) + ([config title _desc _url] [:head [:meta {:charset "utf-8"}] [:meta {:http-equiv "X-UA-Compatible" :content "IE=edge,chrome=1"}] [:meta @@ -409,57 +421,60 @@ [:title title] ;; javascript scripts - (page/include-js "/static/js/dhtmlxgantt.js") - (page/include-js "/static/js/dhtmlxgantt_marker.js") - (page/include-js "/static/js/sorttable.js") - (page/include-js "/static/js/htmx.min.js") - (page/include-js "/static/js/app.js") - (page/include-js "/static/js/highlight.pack.js") - (page/include-js "/static/js/diff.js") - (page/include-js "/static/js/jsondiffpatch.min.js") - (page/include-js "/static/js/jsondiffpatch-formatters.min.js") - (page/include-js "/static/js/diff_match_patch_uncompressed.js") + (page/include-js (asset-path config "/static/js/dhtmlxgantt.js")) + (page/include-js (asset-path config "/static/js/dhtmlxgantt_marker.js")) + (page/include-js (asset-path config "/static/js/sorttable.js")) + (page/include-js (asset-path config "/static/js/htmx.min.js")) + (page/include-js (asset-path config "/static/js/app.js")) + (page/include-js (asset-path config "/static/js/highlight.pack.js")) + (page/include-js (asset-path config "/static/js/diff.js")) + (page/include-js (asset-path config "/static/js/jsondiffpatch.min.js")) + (page/include-js (asset-path config "/static/js/jsondiffpatch-formatters.min.js")) + (page/include-js (asset-path config "/static/js/diff_match_patch_uncompressed.js")) ;; cascade style sheets - (page/include-css "/static/css/app.css") - (page/include-css "/static/css/dhtmlxgantt.css") - (page/include-css "/static/css/json-html.css") - (page/include-css "/static/css/highlight-tomorrow.css") - (page/include-css "/static/css/formatters-styles/html.css") - (page/include-css "/static/css/formatters-styles/annotated.css") - (page/include-css "/static/css/agiladmin.css")])) - -(def navbar-guest - (navbar "guest-nav" - [] - "/")) + (page/include-css (asset-path config "/static/css/app.css")) + (page/include-css (asset-path config "/static/css/dhtmlxgantt.css")) + (page/include-css (asset-path config "/static/css/json-html.css")) + (page/include-css (asset-path config "/static/css/highlight-tomorrow.css")) + (page/include-css (asset-path config "/static/css/formatters-styles/html.css")) + (page/include-css (asset-path config "/static/css/formatters-styles/annotated.css")) + (page/include-css (asset-path config "/static/css/agiladmin.css"))])) + +(defn navbar-guest + ([] (navbar-guest (current-config))) + ([config] + (navbar config "guest-nav" [] (path config "/")))) (defn navbar-account [account] - (navbar "account-nav" - (account-nav-links account) - (account-home-href account))) - -(defn render-footer [] + (let [config (current-config)] + (navbar config "account-nav" + (account-nav-links config account) + (account-home-href config account)))) + +(defn render-footer + ([] (render-footer (current-config))) + ([config] [:footer {:class "mt-16 border-t border-base-300 bg-base-100/80"} [:div {:class "mx-auto flex w-full max-w-screen-2xl flex-col gap-6 px-4 py-8 md:flex-row md:items-center md:justify-between md:px-6"} [:a {:href "https://www.dyne.org" :class "inline-flex items-center"} - [:img {:src "/static/img/dyne-logotype-black.svg" + [:img {:src (asset-path config "/static/img/dyne-logotype-black.svg") :class "h-10 w-auto" :alt "Dyne.org" :data-theme-logo "true" - :data-theme-logo-light "/static/img/dyne-logotype-black.svg" - :data-theme-logo-dark "/static/img/dyne-logotype-white.svg"}]] + :data-theme-logo-light (asset-path config "/static/img/dyne-logotype-black.svg") + :data-theme-logo-dark (asset-path config "/static/img/dyne-logotype-white.svg")}]] [:p [:a {:href "https://github.com/dyne/agiladmin"} "Software"] " by Denis \"Jaromil\" Roio and Manuela Annibali
" "Copyright (C) 2016-2026 by the Dyne.org Foundation"] [:div {:class "flex items-center gap-4 self-start md:self-auto"} - [:img {:src "/static/img/AGPLv3.png" + [:img {:src (asset-path config "/static/img/AGPLv3.png") :class "h-auto max-w-32 opacity-80" :alt "Affero GPLv3 License" - :title "Affero GPLv3 License"}]]]]) + :title "Affero GPLv3 License"}]]]])) ;; highlight functions do no conversion, take the format they highlight ;; render functions take edn and convert to the highlight format @@ -507,7 +522,7 @@ :rows "20" :data-editor "yaml" :id "config" :name "editor"} (yaml/generate-string data)] - [:script {:src "/static/js/ace.js" + [:script {:src (asset-path (current-config) "/static/js/ace.js") :type "text/javascript" :charset "utf-8"}] [:script {:type "text/javascript"} (slurp (io/resource "public/static/js/ace-embed.js"))] @@ -529,44 +544,48 @@ (defonce readme (slurp (io/resource "public/static/README.html"))) -(defonce login-form - [:div {:class "mx-auto max-w-lg"} - [:div {:class "card bg-base-100 shadow-xl"} - [:div {:class "card-body gap-4"} - [:h1 {:class "card-title text-3xl"} "Login into Agiladmin"] - [:form {:action "/login" - :method "post" - :class "space-y-4"} - [:input {:type "text" :name "email" - :placeholder "Email" - :class "input input-bordered w-full"}] - [:input {:type "password" :name "password" - :placeholder "Password" - :class "input input-bordered w-full"}] - [:input {:type "submit" :value "Login" - :class "btn btn-primary btn-lg w-full"}] - [:p {:class "text-sm text-base-content/70"} - "🛡️ Unauthorized access is prohibited. Every visit is recorded."]]]]]) - -(defonce signup-form - [:div {:class "mx-auto max-w-lg"} - [:div {:class "card bg-base-100 shadow-xl"} - [:div {:class "card-body gap-4"} - [:h1 {:class "card-title text-3xl"} "Sign Up Agiladmin"] - [:form {:action "/signup" - :method "post" - :class "space-y-4"} - [:input {:type "text" :name "name" - :placeholder "Display name" - :class "input input-bordered w-full"}] - [:input {:type "text" :name "email" - :placeholder "Email" - :class "input input-bordered w-full"}] - [:input {:type "password" :name "password" - :placeholder "Password" - :class "input input-bordered w-full"}] - [:input {:type "password" :name "repeat-password" - :placeholder "Repeat password" - :class "input input-bordered w-full"}] - [:input {:type "submit" :value "Sign Up" - :class "btn btn-primary btn-lg w-full"}]]]]]) +(defn login-form + ([] (login-form (current-config))) + ([config] + [:div {:class "mx-auto max-w-lg"} + [:div {:class "card bg-base-100 shadow-xl"} + [:div {:class "card-body gap-4"} + [:h1 {:class "card-title text-3xl"} "Login into Agiladmin"] + [:form {:action (path config "/login") + :method "post" + :class "space-y-4"} + [:input {:type "text" :name "email" + :placeholder "Email" + :class "input input-bordered w-full"}] + [:input {:type "password" :name "password" + :placeholder "Password" + :class "input input-bordered w-full"}] + [:input {:type "submit" :value "Login" + :class "btn btn-primary btn-lg w-full"}] + [:p {:class "text-sm text-base-content/70"} + "🛡️ Unauthorized access is prohibited. Every visit is recorded."]]]]])) + +(defn signup-form + ([] (signup-form (current-config))) + ([config] + [:div {:class "mx-auto max-w-lg"} + [:div {:class "card bg-base-100 shadow-xl"} + [:div {:class "card-body gap-4"} + [:h1 {:class "card-title text-3xl"} "Sign Up Agiladmin"] + [:form {:action (path config "/signup") + :method "post" + :class "space-y-4"} + [:input {:type "text" :name "name" + :placeholder "Display name" + :class "input input-bordered w-full"}] + [:input {:type "text" :name "email" + :placeholder "Email" + :class "input input-bordered w-full"}] + [:input {:type "password" :name "password" + :placeholder "Password" + :class "input input-bordered w-full"}] + [:input {:type "password" :name "repeat-password" + :placeholder "Repeat password" + :class "input input-bordered w-full"}] + [:input {:type "submit" :value "Sign Up" + :class "btn btn-primary btn-lg w-full"}]]]]])) diff --git a/test/agiladmin/webpage_test.clj b/test/agiladmin/webpage_test.clj index 010ac07..d8780d8 100644 --- a/test/agiladmin/webpage_test.clj +++ b/test/agiladmin/webpage_test.clj @@ -1,5 +1,6 @@ (ns agiladmin.webpage-test - (:require [agiladmin.webpage :as webpage] + (:require [agiladmin.ring :as ring] + [agiladmin.webpage :as webpage] [hiccup.form :as hf] [hiccup.core :as hiccup] [midje.sweet :refer :all])) @@ -86,3 +87,33 @@ (fact "Guest navigation does not render a redundant login link" (let [html (:body (webpage/render [:div "body"]))] html =not=> (contains ">Login<"))) + +(fact "Shared UI emits root-path assets and form actions by default" + (let [head-html (hiccup/html (webpage/render-head {})) + login-html (hiccup/html (webpage/login-form {}))] + head-html => (contains "src=\"/static/js/app.js\"") + head-html => (contains "href=\"/static/css/app.css\"") + login-html => (contains "action=\"/login\""))) + +(fact "Shared UI emits prefixed assets, links, and actions with a custom base path" + (with-redefs [ring/config (atom {:agiladmin {:webserver {:base-path "/admin"}}})] + (let [head-html (hiccup/html (webpage/render-head)) + login-html (hiccup/html (webpage/login-form)) + nav-links (#'agiladmin.webpage/account-nav-links + {:email "admin@example.org" + :name "Admin User" + :role "admin"}) + button-html (hiccup/html + (webpage/button "/person" + "Open" + (hf/hidden-field "person" "Alice")))] + head-html => (contains "src=\"/admin/static/js/app.js\"") + head-html => (contains "href=\"/admin/static/css/app.css\"") + login-html => (contains "action=\"/admin/login\"") + (set (map :href nav-links)) => #{ + "/admin/persons/list" + "/admin/projects/list" + "/admin/reload" + "/admin/config" + "/admin/logout"} + button-html => (contains "action=\"/admin/person\"")))) From 2b9a8958b13fa551f13359924cb713eeed14065c Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:22:33 +0200 Subject: [PATCH 04/12] test(auth): cover base-path redirects for handlers and login --- test/agiladmin/handlers_test.clj | 10 ++++++++++ test/agiladmin/view_auth_test.clj | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/test/agiladmin/handlers_test.clj b/test/agiladmin/handlers_test.clj index ff529b7..7a33436 100644 --- a/test/agiladmin/handlers_test.clj +++ b/test/agiladmin/handlers_test.clj @@ -48,6 +48,16 @@ (:status response) => 302 (get-in response [:headers "Location"]) => "/persons/list")) +(fact "Root route uses configured base path in redirect locations" + (with-redefs [agiladmin.ring/config + (atom {:agiladmin {:webserver {:base-path "/admin"}}})] + (let [guest (handlers/app-routes (mock/request :get "/")) + authed (handlers/app-routes + (assoc (mock/request :get "/") + :session user-session))] + (get-in guest [:headers "Location"]) => "/admin/login" + (get-in authed [:headers "Location"]) => "/admin/persons/list"))) + (fact "Protected timesheet route falls back to the login form for guests" (let [response (handlers/app-routes (mock/request :get "/timesheets"))] (:status response) => 200 diff --git a/test/agiladmin/view_auth_test.clj b/test/agiladmin/view_auth_test.clj index 5d86d8d..e03cf0f 100644 --- a/test/agiladmin/view_auth_test.clj +++ b/test/agiladmin/view_auth_test.clj @@ -1,5 +1,6 @@ (ns agiladmin.view-auth-test - (:require [agiladmin.view-auth :as view-auth] + (:require [agiladmin.ring :as ring] + [agiladmin.view-auth :as view-auth] [failjure.core] [midje.sweet :refer :all])) @@ -17,6 +18,16 @@ (get-in response [:session :auth :role]) => nil (get-in response [:session :auth :options]) => {:ip-address "127.0.0.1"}))) +(fact "Login redirect location uses configured base path for non-admin users" + (with-redefs [ring/config (atom {:agiladmin {:webserver {:base-path "/admin"}}}) + agiladmin.auth.core/sign-in (fn [username _password _options] + {:email username + :name "User Name"})] + (let [response (view-auth/login-post {:params {:email "user@example.org" + :password "secret"} + :remote-addr "127.0.0.1"})] + (get-in response [:headers "Location"]) => "/admin/persons/list"))) + (fact "Login normalizes legacy admin responses into the admin role" (with-redefs [agiladmin.auth.core/sign-in (fn [username _password _options] {:email username @@ -73,6 +84,12 @@ :name "User Name"}}})] (:body response) => (contains "Already logged in with account: user@example.org"))) +(fact "Login get renders a base-path-aware logout link for active accounts" + (with-redefs [ring/config (atom {:agiladmin {:webserver {:base-path "/admin"}}})] + (let [response (view-auth/login-get {:session {:auth {:email "user@example.org" + :name "User Name"}}})] + (:body response) => (contains "href=\"/admin/logout\"")))) + (fact "Login get includes the unauthorized access warning for logged-out visitors" (let [response (view-auth/login-get {})] (:body response) => (contains "Unauthorized access is prohibited. Every visit is recorded."))) From 17682a1698aea3b6f011d13d3a65a304c4fd7a6b Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:26:52 +0200 Subject: [PATCH 05/12] feat(views): apply base-path helper to domain forms and htmx --- src/agiladmin/handlers.clj | 2 +- src/agiladmin/view_person.clj | 22 ++++---- src/agiladmin/view_project.clj | 9 ++-- src/agiladmin/view_reload.clj | 35 +++++++++---- src/agiladmin/view_timesheet.clj | 72 ++++++++++++++------------ test/agiladmin/view_person_test.clj | 8 +++ test/agiladmin/view_project_test.clj | 11 ++++ test/agiladmin/view_reload_test.clj | 8 +++ test/agiladmin/view_timesheet_test.clj | 8 ++- 9 files changed, 114 insertions(+), 61 deletions(-) diff --git a/src/agiladmin/handlers.clj b/src/agiladmin/handlers.clj index 2573ca7..dc7640a 100644 --- a/src/agiladmin/handlers.clj +++ b/src/agiladmin/handlers.clj @@ -128,7 +128,7 @@ (GET "/timesheets" request (->> (fn [req conf acct] - (web/render acct view-timesheet/upload-form)) + (web/render acct (view-timesheet/upload-form conf))) (s/check request))) (POST "/timesheets/cancel" request (->> view-timesheet/cancel diff --git a/src/agiladmin/view_person.clj b/src/agiladmin/view_person.clj index fa154b1..cbe418a 100644 --- a/src/agiladmin/view_person.clj +++ b/src/agiladmin/view_person.clj @@ -35,15 +35,15 @@ (defn person-download-timesheet - [path] - [:a {:href (str "/timesheets/download/" path)} + [config path] + [:a {:href (web/path config (str "/timesheets/download/" path))} [:button {:type "button" :class "btn btn-primary"} "Download current timesheet"]]) (defn person-download-toolbar - [person year costs] - [:form {:action "/persons/spreadsheet" + [config person year costs] + [:form {:action (web/path config "/persons/spreadsheet") :method "post" :class "space-y-3"} [:h3 "Download yearly totals:"] @@ -91,7 +91,7 @@ account [:div [:h1 (str year " - " (util/dotname person))] - (view-timesheet/upload-card) + (view-timesheet/upload-card config) (f/attempt-all [person-data (load-person-page-data config person year)] (let [{:keys [ts-file timesheet projects hours]} person-data @@ -117,7 +117,7 @@ [:div {:class "month-detail overflow-x-auto"} (to-monthly-hours-table projects breakdown)]]])] [:div {:class "space-y-6"} - (person-download-timesheet ts-file) + (person-download-timesheet config ts-file) [:br] [:div {:class "space-y-6"} [:h1 "Yearly totals"] @@ -181,7 +181,7 @@ (person-buttons old-people))]]) page-body (cond-> [:div {:class "space-y-4"} - (view-timesheet/upload-card) + (view-timesheet/upload-card config) (web/filterable-button-list "persons-list" "Persons" "No persons match the current filter." @@ -212,15 +212,15 @@ account [:div [:h1 (str year " - " (util/dotname person))] - (view-timesheet/upload-card) + (view-timesheet/upload-card config) (f/attempt-all [person-data (load-person-page-data config person year)] (let [{:keys [ts-file timesheet projects hours]} person-data] (f/attempt-all [costs (derive-costs hours config projects) costs-with-cph (derive-cost-per-hour costs config projects)] - [:div {:class "space-y-6"} - (person-download-timesheet ts-file) + [:div {:class "space-y-6"} + (person-download-timesheet config ts-file) [:br] (if (zero? (tab/sum-col costs :cost)) (web/render-error @@ -245,7 +245,7 @@ :Monthly_average monthly-average} vector tab/dataset to-table) (person-download-toolbar - person year + config person year (into [["Date" "Name" "Project" "Task" "Tags" "Hours" "Cost" "CPH"]] (tab/to-row-seq costs-with-cph))) [:div {:class "divider"}] diff --git a/src/agiladmin/view_project.clj b/src/agiladmin/view_project.clj index be83450..35006d1 100644 --- a/src/agiladmin/view_project.clj +++ b/src/agiladmin/view_project.clj @@ -99,19 +99,20 @@ project-buttons (fn [projects] (mapv (fn [project-name] + (let [project-path (web/path config "/project")] [:div {:class "log-project" :data-text-filter-item "true" :data-text-filter-value project-name} - [:form {:action "/project" + [:form {:action project-path :method "post" :class "inline-flex max-w-full w-full" - :hx-post "/project" + :hx-post project-path :hx-target (str "#" project-details-id) :hx-swap "outerHTML"} (hf/hidden-field "project" project-name) [:input {:type "submit" :value project-name - :class "btn btn-outline w-full justify-start"}]]]) + :class "btn btn-outline w-full justify-start"}]]])) projects)) old-projects-section (when (seq old-projects) @@ -148,7 +149,7 @@ ;; else present an editor (web/render account - [:form {:action "/projects/edit" + [:form {:action (web/path config "/projects/edit") :method "post" :class "space-y-4"} [:h1 (str "Project " projname ": edit configuration")] diff --git a/src/agiladmin/view_reload.clj b/src/agiladmin/view_reload.clj index 4913316..a463e74 100644 --- a/src/agiladmin/view_reload.clj +++ b/src/agiladmin/view_reload.clj @@ -35,16 +35,17 @@ body]) (defn- render-reload-page - [request account result-body] - (let [body [:div {:class "space-y-6"} + [request config account result-body] + (let [reload-path (web/path config "/reload") + body [:div {:class "space-y-6"} [:div {:class "card bg-base-100 shadow-sm"} [:div {:class "card-body gap-4"} [:h1 {:class "card-title text-3xl"} "Reload budgets repository"] [:p "Fetch the configured budgets repository and refresh runtime caches after new data is adopted."] - [:form {:action "/reload" + [:form {:action reload-path :method "post" :class "inline-flex" - :hx-post "/reload" + :hx-post reload-path :hx-target (str "#" reload-result-id) :hx-swap "outerHTML"} [:input {:type "submit" @@ -54,31 +55,33 @@ (web/render account body))) (defn- render-reload-response - [request account body] + [request config account body] (let [fragment (reload-result body)] (if (web/htmx-request? request) (web/render-fragment fragment) - (render-reload-page request account body)))) + (render-reload-page request config account body)))) (defn page - [request _config account] + [request config account] (render-reload-page request + config account [:div {:class "alert alert-info shadow-sm"} "Press Reload to fetch the latest budgets repository state."])) (defn- render-reload-message - [request account message] + [request config account message] (render-reload-response request + config account [:div {:class "alert alert-info shadow-sm"} message])) (defn- render-reload-error - [request account message] - (render-reload-response request account (web/render-error message))) + [request config account message] + (render-reload-response request config account (web/render-error message))) (defn- git-ready? [budgets] @@ -107,9 +110,10 @@ (git/git-clone (:git budgets) (:path budgets)))) (defn- render-repo-state-with-message - [request account repo message] + [request config account repo message] (render-reload-response request + config account [:div {:class "space-y-6"} [:div {:class "alert alert-success shadow-sm"} @@ -130,6 +134,7 @@ (not (git-ready? budgets)) (render-reload-message request + config account "Reload is unavailable until :agiladmin :budgets has git, path, and ssh-key configured.") @@ -148,6 +153,7 @@ (core/invalidate-runtime-caches! config) (render-repo-state-with-message request + config account repo (if (= (type pull-result) org.eclipse.jgit.api.PullResult) @@ -160,6 +166,7 @@ [:p (-> ex Throwable->map :cause)]]) (render-reload-error request + config account (str "Error in git-pull: " (.getMessage ex)))))) @@ -169,6 +176,7 @@ (not (empty-directory? path))) (render-reload-message request + config account (str "Budgets path exists but is not a git repository yet: " (:path budgets))) @@ -183,20 +191,24 @@ (if-let [repo (safe-load-repo (:path budgets))] (render-repo-state-with-message request + config account repo (str "Cloned successfully from " (:git budgets))) (render-reload-message request + config account (str "No budgets repository is available yet at " (:path budgets)))) (catch Exception ex (render-reload-error request + config account (str "Error cloning git repo: " (.getMessage ex))))) (render-reload-message request + config account (str "No budgets repository is available yet. Generate or configure SSH keys first: " keypath ".pub"))) @@ -212,6 +224,7 @@ :else (render-reload-error request + config account (str "Unsupported budgets directory state: " (:path budgets))) ;; end of POST /reload diff --git a/src/agiladmin/view_timesheet.clj b/src/agiladmin/view_timesheet.clj index 0fcf4f6..7c2ae03 100644 --- a/src/agiladmin/view_timesheet.clj +++ b/src/agiladmin/view_timesheet.clj @@ -42,28 +42,29 @@ [:div {:id workspace-id :class "space-y-6"} body]) (defn upload-card - [] - [:div {:class "card mx-auto max-w-3xl bg-base-100 shadow-xl"} - [:div {:class "card-body gap-4"} - [:h1 {:class "card-title text-3xl"} "Upload a new timesheet"] - [:p "Choose the file in your computer and click 'Submit' to proceed to validation."] - [:form {:action "/timesheets/upload" - :method "post" - :class "space-y-4" - :enctype "multipart/form-data" - :hx-post "/timesheets/upload" - :hx-target (str "#" workspace-id) - :hx-swap "outerHTML" - :hx-encoding "multipart/form-data"} - [:div {:class "flex items-end gap-3"} - [:input {:name "file" - :type "file" - :class "file-input file-input-bordered w-full"}] - [:input {:class "btn btn-primary btn-lg shrink-0" - :id "field-submit" :type "submit" - :name "submit" :value "submit"}]] - [:p {:class "htmx-indicator text-sm text-base-content/70"} - "Uploading and validating timesheet..."]]]]) + [config] + (let [upload-url (web/path config "/timesheets/upload")] + [:div {:class "card mx-auto max-w-3xl bg-base-100 shadow-xl"} + [:div {:class "card-body gap-4"} + [:h1 {:class "card-title text-3xl"} "Upload a new timesheet"] + [:p "Choose the file in your computer and click 'Submit' to proceed to validation."] + [:form {:action upload-url + :method "post" + :class "space-y-4" + :enctype "multipart/form-data" + :hx-post upload-url + :hx-target (str "#" workspace-id) + :hx-swap "outerHTML" + :hx-encoding "multipart/form-data"} + [:div {:class "flex items-end gap-3"} + [:input {:name "file" + :type "file" + :class "file-input file-input-bordered w-full"}] + [:input {:class "btn btn-primary btn-lg shrink-0" + :id "field-submit" :type "submit" + :name "submit" :value "submit"}]] + [:p {:class "htmx-indicator text-sm text-base-content/70"} + "Uploading and validating timesheet..."]]]])) (defn- render-workspace [request account body] @@ -129,12 +130,13 @@ true))) (defn- action-form - [request url label fields class-name] - (let [attrs (cond-> {:action url + [request config url label fields class-name] + (let [path (web/path config url) + attrs (cond-> {:action path :method "post" :class "inline-flex"} (web/htmx-request? request) - (assoc :hx-post url + (assoc :hx-post path :hx-target (str "#" workspace-id) :hx-swap "outerHTML"))] (into @@ -168,9 +170,10 @@ display.appendChild(fragment);\n }\n window.onload = dodiff;\n")]]]) -(def upload-form +(defn upload-form + [config] (workspace - (upload-card))) + (upload-card config))) (defn cancel [request config account] (f/if-let-ok? [tempfile (s/param request :tempfile)] @@ -178,22 +181,22 @@ window.onload = dodiff;\n")]]]) request account [:div {:class "space-y-4"} - [:div {:class "alert alert-warning shadow-sm" :role "alert"} + [:div {:class "alert alert-warning shadow-sm" :role "alert"} [:span (str "Canceled upload of timesheet: " tempfile " ")] [:span (str "(" (if-not (str/blank? tempfile) (io/delete-file tempfile)) ")")]] - upload-form]) + (upload-form config)]) (web/render-error-page (f/message tempfile)))) (defn- render-upload-error - [request account body] + [request config account body] (render-workspace request account [:div {:class "space-y-4"} body - (upload-card)])) + (upload-card config)])) (defn upload [request config account] (let @@ -206,6 +209,7 @@ window.onload = dodiff;\n")]]]) ;; TODO: put in config (render-upload-error request + config account (web/render-error "File too big in upload.")) :else @@ -215,6 +219,7 @@ window.onload = dodiff;\n")]]]) (if (not (.exists (io/file path))) (render-upload-error request + config account (web/render-error (log/spy :error @@ -231,12 +236,12 @@ window.onload = dodiff;\n")]]]) [:div {:class "flex flex-wrap items-center gap-3 rounded-box border border-info/30 bg-info/10 p-4 text-info-content shadow-sm"} [:span {:class "font-semibold"} (str "Uploaded: " (fs/base-name path))] [:div {:class "ml-auto flex flex-wrap gap-3"} - (action-form request + (action-form request config "/timesheets/cancel" "Cancel" [(hf/hidden-field "tempfile" path)] "btn btn-error btn-lg") - (action-form request + (action-form request config "/timesheets/submit" "Submit" [(hf/hidden-field "path" path)] @@ -273,6 +278,7 @@ window.onload = dodiff;\n")]]]) (f/when-failed [e] (render-upload-error request + config account (log/spy :error [:div [:h1 "Error parsing timesheet"] diff --git a/test/agiladmin/view_person_test.clj b/test/agiladmin/view_person_test.clj index c7a6d94..63f4aee 100644 --- a/test/agiladmin/view_person_test.clj +++ b/test/agiladmin/view_person_test.clj @@ -1,9 +1,17 @@ (ns agiladmin.view-person-test (:require [agiladmin.view-person :as view-person] [clojure.data.json :as json] + [hiccup.core :as hiccup] [failjure.core] [midje.sweet :refer :all])) +(fact "Person download controls honor a configured base path" + (let [config {:agiladmin {:webserver {:base-path "/admin"}}} + download-html (hiccup/html (view-person/person-download-timesheet config "2026_timesheet_User.xlsx")) + toolbar-html (hiccup/html (view-person/person-download-toolbar config "User Name" 2026 [["Date" "Hours"]]))] + download-html => (contains "href=\"/admin/timesheets/download/2026_timesheet_User.xlsx\"") + toolbar-html => (contains "action=\"/admin/persons/spreadsheet\""))) + (fact "Admin personnel view renders a compact filterable persons list" (with-redefs [agiladmin.utils/now (fn [] {:year 2026}) agiladmin.utils/list-direct-files-matching diff --git a/test/agiladmin/view_project_test.clj b/test/agiladmin/view_project_test.clj index e05ce46..1b96cee 100644 --- a/test/agiladmin/view_project_test.clj +++ b/test/agiladmin/view_project_test.clj @@ -55,6 +55,17 @@ (:body response) => (contains "id=\"project-details\"") (:body response) => (contains "Old projects")))) +(fact "Project list paths honor a configured base path" + (with-redefs [agiladmin.utils/now (fn [] {:year 2026 :month 3 :day 31}) + agiladmin.config/project-names (fn [_] ["CORE"]) + agiladmin.config/load-project (fn [_ _] {:CORE {:start_date "01-01-2026" :duration 12}})] + (let [response (view-project/list-all + {} + {:agiladmin {:webserver {:base-path "/admin"}}} + {:email "admin@example.org"})] + (:body response) => (contains "action=\"/admin/project\"") + (:body response) => (contains "hx-post=\"/admin/project\"")))) + (fact "Project start returns only the detail fragment for HTMX requests" (with-redefs [agiladmin.config/load-project (fn [_ _] diff --git a/test/agiladmin/view_reload_test.clj b/test/agiladmin/view_reload_test.clj index 625bdeb..1287f00 100644 --- a/test/agiladmin/view_reload_test.clj +++ b/test/agiladmin/view_reload_test.clj @@ -12,6 +12,14 @@ (:body response) => (contains "hx-post=\"/reload\"") (:body response) => (contains "id=\"reload-result\""))) +(fact "Reload page paths honor a configured base path" + (let [response (view-reload/page + {} + {:agiladmin {:webserver {:base-path "/admin"}}} + {:email "admin@example.org"})] + (:body response) => (contains "action=\"/admin/reload\"") + (:body response) => (contains "hx-post=\"/admin/reload\""))) + (fact "Reload action explains missing budgets git configuration" (let [response (view-reload/start {} diff --git a/test/agiladmin/view_timesheet_test.clj b/test/agiladmin/view_timesheet_test.clj index e63e045..d0d4b1c 100644 --- a/test/agiladmin/view_timesheet_test.clj +++ b/test/agiladmin/view_timesheet_test.clj @@ -7,13 +7,19 @@ [midje.sweet :refer :all])) (fact "Timesheet upload form uses HTMX for progressive enhancement" - (let [html (hiccup/html view-timesheet/upload-form)] + (let [html (hiccup/html (view-timesheet/upload-form {}))] html => (contains "hx-post=\"/timesheets/upload\"") html => (contains "id=\"timesheet-workspace\"") html => (contains "class=\"flex items-end gap-3\"") html => (contains "Uploading and validating timesheet...") html => (contains "shrink-0"))) +(fact "Timesheet upload form paths honor a configured base path" + (let [html (hiccup/html (view-timesheet/upload-form + {:agiladmin {:webserver {:base-path "/admin"}}}))] + html => (contains "action=\"/admin/timesheets/upload\"") + html => (contains "hx-post=\"/admin/timesheets/upload\""))) + (fact "Timesheet upload rejects files above the configured size limit" (let [response (view-timesheet/upload {:params {:file {:size 500001 From 9563a8dc32be9d16fe0c9f0e180a48c1f1ccb1ba Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:28:02 +0200 Subject: [PATCH 06/12] feat(upload): make timesheet max size configurable --- src/agiladmin/view_timesheet.clj | 21 ++++++++++--- test/agiladmin/view_timesheet_test.clj | 42 ++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/agiladmin/view_timesheet.clj b/src/agiladmin/view_timesheet.clj index 7c2ae03..0e45d3b 100644 --- a/src/agiladmin/view_timesheet.clj +++ b/src/agiladmin/view_timesheet.clj @@ -37,6 +37,14 @@ (def workspace-id "timesheet-workspace") +(defn- upload-max-size + "Return the configured upload maximum in bytes with a safe fallback." + [config] + (let [configured (get-in config [:agiladmin :webserver :upload-max-size])] + (if (number? configured) + configured + 500000))) + (defn- workspace [body] [:div {:id workspace-id :class "space-y-6"} body]) @@ -202,16 +210,19 @@ window.onload = dodiff;\n")]]]) (let [tempfile (get-in request [:params :file :tempfile]) filename (get-in request [:params :file :filename]) - params (:params request)] + params (:params request) + max-size (upload-max-size config) + upload-size (or (get-in params [:file :size]) 0)] (cond - (> (get-in params [:file :size]) 500000) - ;; max upload size in bytes - ;; TODO: put in config + (> upload-size max-size) (render-upload-error request config account - (web/render-error "File too big in upload.")) + (web/render-error + (str "File too big in upload. Maximum size is " + max-size + " bytes."))) :else (let [_ (io/copy tempfile (io/file "/tmp" filename)) path (str "/tmp/" filename)] diff --git a/test/agiladmin/view_timesheet_test.clj b/test/agiladmin/view_timesheet_test.clj index d0d4b1c..452c810 100644 --- a/test/agiladmin/view_timesheet_test.clj +++ b/test/agiladmin/view_timesheet_test.clj @@ -20,7 +20,7 @@ html => (contains "action=\"/admin/timesheets/upload\"") html => (contains "hx-post=\"/admin/timesheets/upload\""))) -(fact "Timesheet upload rejects files above the configured size limit" +(fact "Timesheet upload rejects files above the default size limit" (let [response (view-timesheet/upload {:params {:file {:size 500001 :filename "upload.xlsx" @@ -29,7 +29,45 @@ {:email "admin@example.org" :name "Admin User" :role "admin"})] - (:body response) => (contains "File too big in upload."))) + (:body response) => (contains "Maximum size is 500000 bytes."))) + +(fact "Timesheet upload accepts files below a custom configured size limit" + (with-redefs [clojure.java.io/copy (fn [& _] nil) + clojure.java.io/delete-file (fn [& _] nil) + clojure.java.io/file + (fn + ([path] + (proxy [java.io.File] [path] + (exists [] (= path "/tmp/upload.xlsx")))) + ([parent child] + (proxy [java.io.File] [(str parent "/" child)] + (exists [] false)))) + agiladmin.view-timesheet/load-timesheet-owner (fn [_] "Admin User") + agiladmin.core/load-timesheet (fn [_] {:sheets []}) + agiladmin.core/load-all-projects (fn [_] {}) + agiladmin.core/map-timesheets (fn [& _] {:rows []}) + agiladmin.graphics/to-table (fn [_] [:table "hours"])] + (let [response (view-timesheet/upload + {:params {:file {:size 1499 + :filename "upload.xlsx" + :tempfile "/tmp/upload.xlsx"}}} + {:agiladmin {:webserver {:upload-max-size 1500} + :budgets {:path "budgets/"}}} + {:email "admin@example.org" + :name "Admin User" + :role "admin"})] + (:body response) => (contains "Uploaded: upload.xlsx")))) + +(fact "Timesheet upload rejects files above a custom configured size limit" + (let [response (view-timesheet/upload + {:params {:file {:size 1501 + :filename "upload.xlsx" + :tempfile "/tmp/upload.xlsx"}}} + {:agiladmin {:webserver {:upload-max-size 1500}}} + {:email "admin@example.org" + :name "Admin User" + :role "admin"})] + (:body response) => (contains "Maximum size is 1500 bytes."))) (fact "Timesheet upload surfaces spreadsheet parse failures" (with-redefs [clojure.java.io/copy (fn [& _] nil) From 973ae27f60f35974bba6ff6099de05fd8e83fda0 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:30:56 +0200 Subject: [PATCH 07/12] feat(upload): render progress UI in timesheet upload form --- src/agiladmin/view_timesheet.clj | 10 +++++++++- test/agiladmin/view_timesheet_test.clj | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/agiladmin/view_timesheet.clj b/src/agiladmin/view_timesheet.clj index 0e45d3b..f5b6f33 100644 --- a/src/agiladmin/view_timesheet.clj +++ b/src/agiladmin/view_timesheet.clj @@ -68,9 +68,17 @@ [:input {:name "file" :type "file" :class "file-input file-input-bordered w-full"}] - [:input {:class "btn btn-primary btn-lg shrink-0" + [:input {:class "btn btn-primary btn-lg shrink-0" :id "field-submit" :type "submit" :name "submit" :value "submit"}]] + [:div {:class "space-y-2"} + [:progress {:class "progress progress-primary w-full" + :max "100" + :value "0" + :data-upload-progress "true"}] + [:p {:class "text-sm text-base-content/70" + :data-upload-progress-label "true"} + "0%"]] [:p {:class "htmx-indicator text-sm text-base-content/70"} "Uploading and validating timesheet..."]]]])) diff --git a/test/agiladmin/view_timesheet_test.clj b/test/agiladmin/view_timesheet_test.clj index 452c810..183ea14 100644 --- a/test/agiladmin/view_timesheet_test.clj +++ b/test/agiladmin/view_timesheet_test.clj @@ -11,6 +11,8 @@ html => (contains "hx-post=\"/timesheets/upload\"") html => (contains "id=\"timesheet-workspace\"") html => (contains "class=\"flex items-end gap-3\"") + html => (contains "data-upload-progress=\"true\"") + html => (contains "data-upload-progress-label=\"true\"") html => (contains "Uploading and validating timesheet...") html => (contains "shrink-0"))) From 1a814cd5730eeebeeabca339219dcdfa1c55fefe Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:31:02 +0200 Subject: [PATCH 08/12] feat(upload): wire htmx progress events for upload feedback --- resources/public/static/js/app.js | 62 +++++++++++++++++++++++++++++++ test/e2e/timesheet-upload.spec.js | 2 + 2 files changed, 64 insertions(+) diff --git a/resources/public/static/js/app.js b/resources/public/static/js/app.js index cb0bc54..59a62fc 100644 --- a/resources/public/static/js/app.js +++ b/resources/public/static/js/app.js @@ -252,12 +252,74 @@ }); } + function initUploadProgress(root) { + root.querySelectorAll("form").forEach(function (form) { + if (form.dataset.uploadProgressBound === "true") { + return; + } + + var progress = form.querySelector("[data-upload-progress]"); + var label = form.querySelector("[data-upload-progress-label]"); + if (!progress || !label) { + return; + } + + function setProgress(percent) { + var value = Math.max(0, Math.min(100, Math.round(percent))); + progress.value = value; + label.textContent = value + "%"; + } + + function resetProgress() { + setProgress(0); + } + + form.dataset.uploadProgressBound = "true"; + resetProgress(); + + form.addEventListener("htmx:beforeRequest", function (event) { + if (event.target !== form) { + return; + } + resetProgress(); + }); + + form.addEventListener("htmx:xhr:progress", function (event) { + if (event.target !== form) { + return; + } + + var detail = event.detail || {}; + var total = Number(detail.total || 0); + var loaded = Number(detail.loaded || 0); + if (total > 0) { + setProgress((loaded / total) * 100); + } + }); + + form.addEventListener("htmx:afterRequest", function (event) { + if (event.target !== form) { + return; + } + + var successful = event.detail && event.detail.successful; + if (successful) { + setProgress(100); + window.setTimeout(resetProgress, 300); + } else { + resetProgress(); + } + }); + }); + } + function boot(root) { initTabGroups(root); initNavToggles(root); initTextFilters(root); initThemeToggle(root); initPageLoading(root); + initUploadProgress(root); } document.addEventListener("DOMContentLoaded", function () { diff --git a/test/e2e/timesheet-upload.spec.js b/test/e2e/timesheet-upload.spec.js index 734b4ee..c1566d4 100644 --- a/test/e2e/timesheet-upload.spec.js +++ b/test/e2e/timesheet-upload.spec.js @@ -14,6 +14,8 @@ test("admin can login and upload a real timesheet", async ({ page }) => { const state = await readE2EState(); await loginAs(page, "admin"); await openTimesheetUpload(page); + await expect(page.locator("[data-upload-progress]")).toBeVisible(); + await expect(page.locator("[data-upload-progress-label]")).toHaveText("0%"); await uploadTimesheet(page, state.fixtures.admin); await expect(page.getByText("Uploaded: 2016_timesheet_Luca-Pacioli.xlsx")).toBeVisible(); From 9b1c3165d1fe1f7832d86d640b1476f983b7f099 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:33:07 +0200 Subject: [PATCH 09/12] chore(config): align docs and e2e config with base path defaults --- README.md | 6 ++++++ doc/agiladmin.pocketbase.yaml | 3 +++ doc/agiladmin.pocketbase.yaml.in | 3 +++ packaging/README.md | 16 ++++++++++++++++ scripts/e2e/start-agiladmin.mjs | 4 ++++ 5 files changed, 32 insertions(+) diff --git a/README.md b/README.md index 1f06354..ad39a12 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,9 @@ agiladmin: webserver: host: localhost port: 8000 + base-host: "" + base-path: / + upload-max-size: 500000 anti-forgery: false ssl-redirect: false @@ -231,6 +234,9 @@ Notes: - `budgets.ssh-key` is the private key path used for Git access; if it does not exist, Agiladmin generates a new keypair and exposes the public key in the `/config` page - project names are discovered from `*.yaml` files in `budgets.path`, using the part of the filename before the first `.` - `pocketbase` is optional only if you are using dev auth locally +- `webserver.upload-max-size` is in bytes and defaults to `500000` +- `webserver.base-path` is the browser-visible mount prefix; if you publish under a subpath such as `/agiladmin`, your reverse proxy must strip that prefix before forwarding to Jetty routes +- when TLS terminates at Caddy or another reverse proxy, keep `webserver.ssl-redirect: false` and let the proxy handle HTTP to HTTPS redirects ## Project Configuration diff --git a/doc/agiladmin.pocketbase.yaml b/doc/agiladmin.pocketbase.yaml index 6af44f9..b52b719 100644 --- a/doc/agiladmin.pocketbase.yaml +++ b/doc/agiladmin.pocketbase.yaml @@ -6,6 +6,9 @@ agiladmin: ssl-redirect: false port: 8000 host: localhost + base-host: "" + base-path: / + upload-max-size: 500000 budgets: git: ssh://git@example.org/admin-budgets diff --git a/doc/agiladmin.pocketbase.yaml.in b/doc/agiladmin.pocketbase.yaml.in index 0a6aacf..a08a50b 100644 --- a/doc/agiladmin.pocketbase.yaml.in +++ b/doc/agiladmin.pocketbase.yaml.in @@ -6,6 +6,9 @@ agiladmin: ssl-redirect: false port: 8000 host: localhost + base-host: "" + base-path: / + upload-max-size: 500000 budgets: git: ssh://git@example.org/admin-budgets diff --git a/packaging/README.md b/packaging/README.md index 0a57195..1351ca8 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -66,6 +66,22 @@ Agiladmin reads the budgets directory from the instance config file, not from `% The top-level `make install` now renders the default instance config from a template, so the installed `agiladmin.yaml` uses the active `APP_HOME` and instance name instead of copying a static sample verbatim. +## Reverse Proxy Notes (Caddy) + +Use `agiladmin.webserver.base-host` for the public origin and `agiladmin.webserver.base-path` for the public path prefix. Keep internal Jetty routes unchanged. + +If Agiladmin is published at a subpath (for example `/agiladmin`), the proxy must strip that prefix before forwarding to Jetty. A minimal Caddy pattern is: + +```caddyfile +example.org { + handle_path /agiladmin/* { + reverse_proxy 127.0.0.1:8000 + } +} +``` + +When TLS terminates at Caddy, keep `agiladmin.webserver.ssl-redirect: false` in Agiladmin config and let Caddy handle HTTP to HTTPS redirects. + The PocketBase unit uses `User=@APP_NAME@` and `Group=@APP_NAME@`. Create that service account before enabling the unit, for example: ```sh diff --git a/scripts/e2e/start-agiladmin.mjs b/scripts/e2e/start-agiladmin.mjs index 71f2f43..3ffa6ed 100644 --- a/scripts/e2e/start-agiladmin.mjs +++ b/scripts/e2e/start-agiladmin.mjs @@ -28,7 +28,11 @@ function yamlConfig(budgetsPath, sshKeyPath) { " webserver:", " host: 127.0.0.1", " port: 18080", + " base-host: \"\"", + " base-path: /", + " upload-max-size: 500000", " anti-forgery: false", + " ssl-redirect: false", ].join("\n"); } From 15b3fd7a63cda37f25990d5bc02bbc4425bde756 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:37:59 +0200 Subject: [PATCH 10/12] test(e2e): add base-path browser coverage --- package.json | 1 + playwright.config.mjs | 2 +- scripts/e2e/run-playwright.mjs | 86 ++++++++++++++++++++++++++++++++- scripts/e2e/start-agiladmin.mjs | 11 ++++- test/e2e/authentication.spec.js | 2 +- test/e2e/base-path.spec.js | 20 ++++++++ test/e2e/helpers/agiladmin.js | 6 +-- 7 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 test/e2e/base-path.spec.js diff --git a/package.json b/package.json index 9d15c48..bc60353 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "sync:htmx": "node ./scripts/sync-frontend-assets.mjs", "build:frontend": "npm run sync:htmx && npm run build:css", "test:e2e": "node ./scripts/e2e/run-playwright.mjs", + "test:e2e:base-path": "E2E_BASE_PATH=/agiladmin node ./scripts/e2e/run-playwright.mjs test/e2e/base-path.spec.js", "test:e2e:headed": "node ./scripts/e2e/run-playwright.mjs --headed", "test:e2e:debug": "node ./scripts/e2e/run-playwright.mjs --debug" }, diff --git a/playwright.config.mjs b/playwright.config.mjs index 8c480ed..986f20e 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -4,7 +4,7 @@ export default defineConfig({ testDir: "./test/e2e", retries: process.env.CI ? 1 : 0, use: { - baseURL: "http://127.0.0.1:18080", + baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:18080", trace: "on-first-retry", screenshot: "only-on-failure", video: "retain-on-failure", diff --git a/scripts/e2e/run-playwright.mjs b/scripts/e2e/run-playwright.mjs index 143ee47..750db48 100644 --- a/scripts/e2e/run-playwright.mjs +++ b/scripts/e2e/run-playwright.mjs @@ -1,8 +1,26 @@ import { spawn } from "node:child_process"; +import http from "node:http"; import { setTimeout as sleep } from "node:timers/promises"; const args = process.argv.slice(2); +const BACKEND_ORIGIN = "http://127.0.0.1:18080"; +const PROXY_ORIGIN = "http://127.0.0.1:18081"; +const E2E_BASE_PATH = normalizeBasePath(process.env.E2E_BASE_PATH ?? "/"); let server; +let proxyServer; + +function normalizeBasePath(basePath) { + const raw = String(basePath ?? "").trim(); + if (!raw || raw === "/") return "/"; + const cleaned = raw.replace(/^\/+/, "").replace(/\/+$/, ""); + return cleaned ? `/${cleaned}` : "/"; +} + +function withBasePath(pathname, basePath) { + if (basePath === "/") return pathname; + const route = pathname.startsWith("/") ? pathname : `/${pathname}`; + return `${basePath}${route}`; +} async function waitForLogin(url, timeoutMs = 90000) { const started = Date.now(); @@ -18,6 +36,52 @@ async function waitForLogin(url, timeoutMs = 90000) { throw new Error(`Timed out waiting for ${url}`); } +function startPrefixProxy(basePath) { + const prefix = basePath; + const server = http.createServer((req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", PROXY_ORIGIN); + const pathOnly = requestUrl.pathname; + const hasPrefix = pathOnly === prefix || pathOnly.startsWith(`${prefix}/`); + const strippedPath = hasPrefix + ? pathOnly === prefix + ? "/" + : pathOnly.slice(prefix.length) + : pathOnly; + const targetPath = `${strippedPath}${requestUrl.search}`; + const proxyReq = http.request( + { + protocol: "http:", + hostname: "127.0.0.1", + port: 18080, + method: req.method, + path: targetPath, + headers: { + ...req.headers, + host: "127.0.0.1:18080", + }, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(res); + }, + ); + proxyReq.on("error", (err) => { + res.statusCode = 502; + res.end(`proxy error: ${err.message}`); + }); + req.pipe(proxyReq); + } catch (err) { + res.statusCode = 500; + res.end(`proxy setup error: ${err.message}`); + } + }); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(18081, "127.0.0.1", () => resolve(server)); + }); +} + async function main() { server = spawn("node", ["./scripts/e2e/start-agiladmin.mjs"], { stdio: "inherit", @@ -30,11 +94,20 @@ async function main() { } }); - await waitForLogin("http://127.0.0.1:18080/login"); + if (E2E_BASE_PATH !== "/") { + proxyServer = await startPrefixProxy(E2E_BASE_PATH); + } + + const loginUrl = E2E_BASE_PATH === "/" ? `${BACKEND_ORIGIN}/login` : `${PROXY_ORIGIN}/login`; + await waitForLogin(loginUrl); + const baseURL = E2E_BASE_PATH === "/" ? BACKEND_ORIGIN : PROXY_ORIGIN; const runner = spawn("npx", ["playwright", "test", ...args], { stdio: "inherit", - env: process.env, + env: { + ...process.env, + PLAYWRIGHT_BASE_URL: baseURL, + }, }); const testCode = await new Promise((resolve) => { @@ -44,6 +117,9 @@ async function main() { if (server && !server.killed) { server.kill("SIGTERM"); } + if (proxyServer) { + await new Promise((resolve) => proxyServer.close(resolve)); + } process.exit(testCode); } @@ -52,6 +128,9 @@ for (const signal of ["SIGINT", "SIGTERM"]) { if (server && !server.killed) { server.kill("SIGTERM"); } + if (proxyServer) { + proxyServer.close(); + } process.exit(130); }); } @@ -61,5 +140,8 @@ main().catch((err) => { if (server && !server.killed) { server.kill("SIGTERM"); } + if (proxyServer) { + proxyServer.close(); + } process.exit(1); }); diff --git a/scripts/e2e/start-agiladmin.mjs b/scripts/e2e/start-agiladmin.mjs index 3ffa6ed..88d4f45 100644 --- a/scripts/e2e/start-agiladmin.mjs +++ b/scripts/e2e/start-agiladmin.mjs @@ -11,6 +11,14 @@ const STATE_PATH = path.join(STATE_DIR, "agiladmin-e2e-state.json"); const OUTPUT_DIR = path.join(REPO_ROOT, "output", "playwright"); const LOG_PATH = path.join(OUTPUT_DIR, "agiladmin-server.log"); const DEBUG_E2E = process.env.DEBUG_E2E === "1"; +const E2E_BASE_PATH = normalizeBasePath(process.env.E2E_BASE_PATH ?? "/"); + +function normalizeBasePath(basePath) { + const raw = String(basePath ?? "").trim(); + if (!raw || raw === "/") return "/"; + const cleaned = raw.replace(/^\/+/, "").replace(/\/+$/, ""); + return cleaned ? `/${cleaned}` : "/"; +} function yamlConfig(budgetsPath, sshKeyPath) { return [ @@ -29,7 +37,7 @@ function yamlConfig(budgetsPath, sshKeyPath) { " host: 127.0.0.1", " port: 18080", " base-host: \"\"", - " base-path: /", + ` base-path: ${E2E_BASE_PATH}`, " upload-max-size: 500000", " anti-forgery: false", " ssl-redirect: false", @@ -109,6 +117,7 @@ async function prepareEnv() { manager: managerFixturePath, guest: guestFixturePath, }, + basePath: E2E_BASE_PATH, logPath: LOG_PATH, }; await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); diff --git a/test/e2e/authentication.spec.js b/test/e2e/authentication.spec.js index 693e4d3..b61ad9a 100644 --- a/test/e2e/authentication.spec.js +++ b/test/e2e/authentication.spec.js @@ -26,7 +26,7 @@ test("invalid credentials fail without creating a session", async ({ page }) => await page.goto("/login"); await page.locator('input[name="email"]').fill("admin"); await page.locator('input[name="password"]').fill("wrong-password"); - await page.locator('form[action="/login"] input[type="submit"]').click(); + await page.locator('form[action$="/login"] input[type="submit"]').click(); await expect(page.getByText("Login failed:")).toBeVisible(); await expect(page.getByRole("link", { name: "Logout" })).toHaveCount(0); diff --git a/test/e2e/base-path.spec.js b/test/e2e/base-path.spec.js new file mode 100644 index 0000000..ddeb546 --- /dev/null +++ b/test/e2e/base-path.spec.js @@ -0,0 +1,20 @@ +import { test, expect } from "@playwright/test"; +import { loginAs } from "./helpers/agiladmin.js"; + +test("base-path mode renders prefixed URLs for login and upload", async ({ page }) => { + const basePath = process.env.E2E_BASE_PATH || "/"; + test.skip(basePath === "/", "Set E2E_BASE_PATH to run base-path browser coverage."); + + await page.goto("/login"); + await expect(page.locator(`form[action="${basePath}/login"]`)).toBeVisible(); + await expect(page.locator(`script[src="${basePath}/static/js/app.js"]`)).toHaveCount(1); + await expect(page.locator(`link[href="${basePath}/static/css/app.css"]`)).toHaveCount(1); + + await loginAs(page, "admin"); + await expect(page.getByText("Logged in: admin")).toBeVisible(); + await expect(page.locator(`a[href="${basePath}/logout"]`).first()).toBeVisible(); + + await page.goto("/timesheets"); + await expect(page.locator(`form[action="${basePath}/timesheets/upload"]`)).toBeVisible(); + await expect(page.locator(`#timesheet-workspace [hx-post="${basePath}/timesheets/upload"]`)).toHaveCount(1); +}); diff --git a/test/e2e/helpers/agiladmin.js b/test/e2e/helpers/agiladmin.js index 7160e6e..2a0c385 100644 --- a/test/e2e/helpers/agiladmin.js +++ b/test/e2e/helpers/agiladmin.js @@ -22,7 +22,7 @@ export async function login(page, username, password) { await page.goto("/login"); await page.locator('input[name="email"]').fill(username); await page.locator('input[name="password"]').fill(password); - await page.locator('form[action="/login"] input[type="submit"]').click(); + await page.locator('form[action$="/login"] input[type="submit"]').click(); } export async function expectLoggedInAs(page, username) { @@ -30,7 +30,7 @@ export async function expectLoggedInAs(page, username) { } export async function expectLoginForm(page) { - await expect(page.locator('form[action="/login"]')).toBeVisible(); + await expect(page.locator('form[action$="/login"]')).toBeVisible(); await expect(page.locator('input[name="email"]')).toBeVisible(); await expect(page.locator('input[name="password"]')).toBeVisible(); } @@ -44,7 +44,7 @@ export async function loginAs(page, role) { if (role === "guest") { await expect(page).toHaveURL(/\/persons\/list$/); await expect(page.getByRole("link", { name: "Logout" })).toBeVisible(); - await expect(page.locator('form[action="/login"]')).toHaveCount(0); + await expect(page.locator('form[action$="/login"]')).toHaveCount(0); return; } await expectLoggedInAs(page, user.username); From 91ab70b6ae5aec6250dfa493359938476fd605dd Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:43:30 +0200 Subject: [PATCH 11/12] test(e2e): reduce flaky project detail waits --- test/e2e/project-access.spec.js | 2 +- test/e2e/project-visibility.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/project-access.spec.js b/test/e2e/project-access.spec.js index 6800e62..13af9f9 100644 --- a/test/e2e/project-access.spec.js +++ b/test/e2e/project-access.spec.js @@ -15,7 +15,7 @@ async function openFirstProjectFromList(page) { } expect(clicked).toBeTruthy(); - await expect(page.locator("#project-details .tabs")).toBeVisible(); + await expect(page.locator("#project-details .tabs")).toBeVisible({ timeout: 15000 }); } test("admin can access project list and open a project", async ({ page }) => { diff --git a/test/e2e/project-visibility.spec.js b/test/e2e/project-visibility.spec.js index c0c3260..f16808e 100644 --- a/test/e2e/project-visibility.spec.js +++ b/test/e2e/project-visibility.spec.js @@ -16,7 +16,7 @@ async function openFirstProjectFromList(page) { } expect(clicked).toBeTruthy(); - await expect(page.locator("#project-details .tabs")).toBeVisible(); + await expect(page.locator("#project-details .tabs")).toBeVisible({ timeout: 15000 }); } test("admin project view shows edit controls and cost data", async ({ page }) => { From a7079c68251a81cdc99d5098192f90591f2ed8f7 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 20 May 2026 09:43:34 +0200 Subject: [PATCH 12/12] docs(agents): document base-path URL helper rules --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3b3c578..0ce16d5 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,10 @@ - `:webserver` - `:source` - `:just-auth` +- `:agiladmin :webserver` now separates internal bind settings from public URL settings: + - `:host` and `:port` are Jetty bind values. + - `:base-host` and `:base-path` are browser-facing URL parts. + - `:upload-max-size` configures upload byte limits (default `500000`). - Project configs are separate YAML files stored under the configured budgets path and loaded by `load-project`. - Tests use fixture config under `test/assets/agiladmin.yaml`. @@ -125,6 +129,7 @@ - HTMX is loaded locally from `resources/public/static/js/htmx.min.js` and is intended for progressive enhancement only; keep full-page fallback behavior working. - Current HTMX seams follow the same pattern: the normal route remains authoritative and returns a full page, while `web/htmx-request?` switches selected actions to fragment responses. Existing examples are `POST /reload`, `POST /timesheets/upload`, and `POST /project`. - `resources/public/static/js/app.js` replaces the old Bootstrap JS for navbar toggles and tab switching. +- Browser-facing app URLs should be generated via `agiladmin.webpage/path` / `asset-path` helpers rather than hard-coded `"/..."` strings; route definitions remain root paths and reverse proxies are expected to strip any configured public `base-path`. - DHTMLX Gantt remains a JS island. Do not rewrite it into HTMX; only change the surrounding shell unless the task explicitly calls for deeper work. ## Useful Files