Skip to content

Commit d7ea962

Browse files
committed
feat(auth): new auth provider + hook
1 parent 39a92e5 commit d7ea962

File tree

7 files changed

+227
-15
lines changed

7 files changed

+227
-15
lines changed

src/main/parts/frontend/api/core.cljs

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
;; Helpers
66

7-
(defn- get-auth-token []
8-
(js/lcalStorage.getItem "auth-token"))
7+
(defn get-auth-token []
8+
(js/localStorage.getItem "auth-token"))
99

10-
(defn- add-auth-header [req]
10+
(defn add-auth-header [req]
1111
(if-let [token (js/localStorage.getItem "auth-token")]
1212
(assoc-in req [:headers "Authorization"] (str "Bearer " token))
1313
req))

src/main/parts/frontend/app.cljs

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
(ns parts.frontend.app
22
(:require
33
["htmx.org" :default htmx]
4-
[parts.frontend.api.core :as api]
4+
[parts.frontend.context :refer [auth-provider]]
55
[parts.frontend.components.system :refer [system]]
66
[uix.core :refer [defui $]]
77
[uix.dom]))
@@ -16,7 +16,8 @@
1616
{:id "e3-2" :source "3" :target "2"}]})
1717

1818
(defui app []
19-
($ system system-data))
19+
($ auth-provider {}
20+
($ system system-data)))
2021

2122
(defonce root
2223
(when-let [root-element (js/document.getElementById "root")]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
(ns parts.frontend.components.login-modal
2+
(:require
3+
[uix.core :refer [defui $ use-state]]
4+
[cljs.core.async :refer [<!]]
5+
[parts.frontend.context :as ctx]
6+
[parts.frontend.utils.csrf :as csrf]
7+
[parts.frontend.components.modal :refer [modal]])
8+
(:require-macros
9+
[cljs.core.async.macros :refer [go]]))
10+
11+
(defui login-modal [{:keys [show on-close]}]
12+
(let [[email set-email] (use-state "")
13+
[password set-password] (use-state "")
14+
[error set-error] (use-state nil)
15+
[loading set-loading] (use-state false)
16+
{:keys [login]} (ctx/use-auth)
17+
18+
handle-submit (fn [e]
19+
(.preventDefault e)
20+
(set-loading true)
21+
(set-error nil)
22+
(go
23+
(try
24+
(let [_result (<! (login {:email email
25+
:password password}))]
26+
(set-loading false)
27+
(on-close))
28+
(catch js/Error err
29+
(js/console.log "Login error:", err)
30+
(set-loading false)
31+
(set-error "Invalid email or password")))))]
32+
33+
($ modal
34+
{:show show
35+
:title "Log in"
36+
:on-close on-close}
37+
38+
($ :form {:on-submit handle-submit}
39+
(when error
40+
($ :div {:class "error-message"}
41+
error))
42+
43+
(when-let [token (csrf/get-token)]
44+
($ :input
45+
{:type "hidden"
46+
:id "__anti-forgery-token"
47+
:name "__anti-forgery-token"
48+
:value token}))
49+
50+
($ :div {:class "form-group"}
51+
($ :label {:for "email"} "Email:")
52+
($ :input
53+
{:type "email"
54+
:id "email"
55+
:value email
56+
:disabled loading
57+
:on-change #(set-email (.. % -target -value))
58+
:required true}))
59+
60+
($ :div {:class "form-group"}
61+
($ :label {:for "password"} "Password:")
62+
($ :input
63+
{:type "password"
64+
:id "password"
65+
:value password
66+
:disabled loading
67+
:on-change #(set-password (.. % -target -value))
68+
:required true}))
69+
70+
($ :div {:class "form-actions"}
71+
($ :button
72+
{:type "button"
73+
:disabled loading
74+
:on-click on-close}
75+
"Cancel")
76+
($ :button
77+
{:type "submit"
78+
:disabled loading
79+
:class "primary"}
80+
(if loading "Logging in..." "Log in")))))))

src/main/parts/frontend/components/system.cljs

+6-4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
[clojure.string :as str]
1313
[parts.frontend.components.nodes :refer [node-types]]
1414
[parts.frontend.components.toolbar :refer [parts-toolbar]]
15+
[parts.frontend.components.login-modal :refer [login-modal]]
1516
[parts.frontend.utils.node-utils :refer [build-updated-part]]
1617
[parts.frontend.context :as ctx]))
1718

@@ -53,16 +54,17 @@
5354

5455
(defui auth-status-bar []
5556
(let [[show-login-modal set-show-login-modal] (use-state false)
56-
{:keys [logged-in email logout]} (uix.core/use-context ctx/auth-context)]
57+
{:keys [user loading logout]} (ctx/use-auth)]
5758
($ :div
5859
(when show-login-modal
5960
($ login-modal
6061
{:show true
6162
:on-close #(set-show-login-modal false)}))
62-
63-
(if logged-in
63+
(when loading
64+
($ :span "(...) "))
65+
(if user
6466
($ :span
65-
($ :span {:class "user-email"} (str "🟢 " email))
67+
($ :span {:class "user-email"} (str "🟢 " (:email user)))
6668
($ :button {:on-click (fn []
6769
(.then (logout) (fn [_] nil)))}
6870
"Log out"))

src/main/parts/frontend/context.cljs

+53-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,57 @@
11
(ns parts.frontend.context
2-
(:require [uix.core]))
2+
(:require
3+
[clojure.core.async :refer [<!]]
4+
[parts.frontend.api.core :as api]
5+
[uix.core :refer [$ defui use-state use-effect use-context]])
6+
(:require-macros
7+
[cljs.core.async.macros :refer [go]]))
38

49
(def update-node-context (uix.core/create-context nil))
510

6-
(def auth-context (uix.core/create-context
7-
{:logged-in false
8-
:email nil
9-
:login nil
10-
:logout nil}))
11+
(def auth-context (uix.core/create-context
12+
{:logged-in false
13+
:email nil
14+
:login nil
15+
:logout nil}))
16+
17+
(defui auth-provider [{:keys [children]}]
18+
(let [[user set-user] (use-state nil)
19+
[loading set-loading] (use-state true)
20+
fetch-user! (fn []
21+
(go
22+
(let [resp (<! (api/get-current-user))]
23+
(set-loading false)
24+
(when (= 200 (:status resp))
25+
(set-user (:body resp))))))
26+
login! (fn [creds]
27+
(go
28+
(let [resp (<! (api/login creds))]
29+
(when (= 200 (:status resp))
30+
(<! (fetch-user!)))
31+
resp)))
32+
logout! (fn []
33+
(api/logout)
34+
(set-user nil))
35+
value {:user user
36+
:loading loading
37+
:login login!
38+
:logout logout!}]
39+
40+
(use-effect
41+
(fn []
42+
(println "[auth-provider] checking for token")
43+
(if (api/get-auth-token)
44+
(do
45+
(println "[auth-provider] token found")
46+
(fetch-user!))
47+
(set-loading false)))
48+
[fetch-user!])
49+
50+
($ auth-context.Provider {:value value}
51+
children)))
52+
53+
(defn use-auth []
54+
(let [auth (use-context auth-context)]
55+
(when (nil? auth)
56+
(js/console.error "use-auth must be used within an AuthProvider"))
57+
auth))
+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
(ns parts.frontend.utils.csrf)
2+
3+
(defn get-token
4+
"Get the CSRF token from the meta tag"
5+
[]
6+
(when-let [meta-tag (.querySelector js/document "meta[name='csrf-token']")]
7+
(.getAttribute meta-tag "content")))

test/parts/routes_test.clj

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
(ns parts.routes-test
2+
(:require
3+
[clojure.test :refer [deftest is testing use-fixtures]]
4+
[muuntaja.core :as m]
5+
[parts.api.auth :as auth]
6+
[parts.helpers.utils :refer [with-test-db register-test-user]]
7+
[parts.helpers.test-factory :as factory]
8+
[parts.entity.user :as user]
9+
[parts.routes :as routes]
10+
[reitit.ring :as ring]
11+
[ring.mock.request :as mock]
12+
[cognitect.transit :as transit])
13+
(:import
14+
[java.io ByteArrayInputStream ByteArrayOutputStream]))
15+
16+
(use-fixtures :once with-test-db)
17+
18+
(defn- parse-transit [body]
19+
(let [in (ByteArrayInputStream. (.getBytes body))
20+
reader (transit/reader in :json)]
21+
(transit/read reader)))
22+
23+
(defn- create-app
24+
"Creates a test app with all middleware configured as in production"
25+
[]
26+
(ring/ring-handler
27+
(ring/router routes/routes)))
28+
29+
(deftest test-login-handler
30+
(testing "login handler with middleware stack"
31+
(let [app (create-app)
32+
user-data (factory/create-test-user)
33+
{:keys [email password]} user-data]
34+
35+
;; First create the user
36+
(user/create! user-data)
37+
38+
;; Create login request with transit format
39+
(let [request (-> (mock/request :post "/api/auth/login")
40+
(mock/json-body {:email email :password password})
41+
(mock/header "Content-Type" "application/transit+json")
42+
(mock/header "Accept" "application/transit+json"))
43+
response (app request)
44+
body (parse-transit (:body response))]
45+
46+
;; Test response status, headers and content
47+
(is (= 200 (:status response)))
48+
(is (= "application/transit+json; charset=utf-8"
49+
(get-in response [:headers "Content-Type"])))
50+
(is (:access_token body))
51+
(is (:refresh_token body))
52+
(is (= "Bearer" (:token_type body)))))))
53+
54+
(deftest test-unauthorized-access
55+
(testing "protected endpoints require authentication"
56+
(let [app (create-app)
57+
request (-> (mock/request :get "/api/account")
58+
(mock/header "Accept" "application/transit+json"))
59+
response (app request)
60+
body (parse-transit (:body response))]
61+
62+
(is (= 401 (:status response)))
63+
(is (= "application/transit+json; charset=utf-8"
64+
(get-in response [:headers "Content-Type"])))
65+
(is (= "Unauthorized" (:error body))))))
66+
67+
(deftest test-static-resource-content-type
68+
(testing "SVG files are served with correct content type"
69+
(let [app (create-app)
70+
request (mock/request :get "/images/parts-logo-horizontal.svg")
71+
response (app request)]
72+
73+
(is (= 200 (:status response)))
74+
(is (= "image/svg+xml"
75+
(get-in response [:headers "Content-Type"]))))))

0 commit comments

Comments
 (0)