Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
285a7e3
hash session keys
Mar 31, 2026
e1590f0
configurable key-length
Mar 31, 2026
80b0329
refactor SignupPage
Mar 31, 2026
49282d9
rm LoginStyle
Mar 31, 2026
6929486
read secret-key from env
Mar 31, 2026
7f889d7
sha-512 aero reader
Apr 12, 2026
61dda08
t/seconds-from-now
Apr 12, 2026
51b1b84
correct let binding name
Apr 12, 2026
6017349
fix t/seconds-from-now
Apr 12, 2026
c6720d2
s/invitation-query/invitation-queries
Apr 12, 2026
7757eac
fix arity-1 seconds-ago
Apr 12, 2026
bcc0805
lint
Apr 12, 2026
2b8e98c
test and fix internal.time
Apr 12, 2026
ee2ca19
check invitation age
Apr 12, 2026
bb9462e
refactor signup to not use full ::bread/config map
Apr 12, 2026
f2cb31b
test custom auth/signup config
Apr 12, 2026
57bbd00
lint
Apr 12, 2026
622d60a
test ::signup/validate
Apr 12, 2026
8fb7471
test existing-username check
Apr 12, 2026
47cb77f
test ::signup/check-invitation-age
Apr 13, 2026
24ad3aa
explicitly support zero, negative values for invitation-expiration-se…
Apr 13, 2026
6bde1d3
test global session invalidation
Apr 13, 2026
88f8307
query auth/database for sensitive queries
Apr 13, 2026
3d7a8a2
cleanup signup-test
Apr 13, 2026
eebb6e1
test-signup-flow
Apr 13, 2026
01fccf1
cleanup database, log initial-txns
Apr 13, 2026
193e3b7
configure not-found-component for /_/signup
Apr 13, 2026
b5c3e0c
hash invitations using secret-key
Apr 13, 2026
3e0cb2d
simplify signup flow test
Apr 13, 2026
ceb9571
account for :auth/secret-key in signup-test
Apr 13, 2026
f8b64c9
improve signup test coverage
Apr 13, 2026
18c02a8
dissoc body in ::ring/response
Apr 13, 2026
9d0404b
cleanup
Apr 13, 2026
8d7f9ad
refactor ::component/render
Apr 13, 2026
c945b05
fix signup-flow-test ns
Apr 13, 2026
99827fc
refactor: ::signup/render
Apr 13, 2026
735bdb7
respond with 400 on invalid signup attempt
Apr 13, 2026
30d4ed4
display password guidelines
Apr 13, 2026
573f4ec
port ::account-form
Apr 13, 2026
a63f720
password guidelines on account page
Apr 13, 2026
1687126
fix EmailPage
Apr 13, 2026
a8c86b8
port ::account/timezone
Apr 13, 2026
2ea561b
port ::account/sessions
Apr 13, 2026
0dec865
finish porting Sections to RISE
Apr 13, 2026
533dfdf
custom validation TODO...
Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ SMTP_PASSWORD=secret
STORE_BACKEND=jdbc
# Alternatively:
# STORE_BACKEND=mem

AUTH_SECRET_KEY=qwerty
6 changes: 5 additions & 1 deletion cms/core/src/systems/bread/alpha/cms/config/buddy.cljc
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
(ns systems.bread.alpha.cms.config.buddy
(:require
[aero.core :as aero]
[buddy.hashers :as hashers]))
[buddy.hashers :as hashers]
[systems.bread.alpha.internal.interop :refer [sha-512]]))

(defmethod aero/reader 'buddy/derive [_ _ [pw algo]]
(hashers/derive pw (when algo {:alg algo})))

(defmethod aero/reader 'sha-512 [_ _ s]
(sha-512 s))
4 changes: 3 additions & 1 deletion cms/core/src/systems/bread/alpha/cms/main.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@
["/signup"
{:name :signup
:dispatcher/type ::signup/signup=>
:dispatcher/component #'rise/SignupPage}]]
:dispatcher/component #'rise/SignupPage
:dispatcher/not-found-component #'rise/SignupPage}]]
["assets/*"
(reitit.ring/create-resource-handler
{})]
Expand Down Expand Up @@ -408,6 +409,7 @@
(:http @system)
(:ring/wrap-defaults @system)
(:ring/session-store @system)
(-> @system :initial-config :ring/session-store :secret-key)
(:bread/app @system)
(:bread/routes @system)
(:bread/router @system)
Expand Down
227 changes: 181 additions & 46 deletions cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
(ns systems.bread.alpha.cms.theme.rise
(:require
[clojure.java.io :as io]
[clojure.string :as string]

[systems.bread.alpha.cms.theme :as theme]
[systems.bread.alpha.component :refer [defc Section]]
[systems.bread.alpha.i18n :as i18n]
[systems.bread.alpha.plugin.account :as account]
[systems.bread.alpha.plugin.email :as email]
[systems.bread.alpha.plugin.auth :as auth]
[systems.bread.alpha.plugin.invitations :as invitations]))
[systems.bread.alpha.plugin.invitations :as invitations])
(:import
[java.text SimpleDateFormat]))

(defn- IntroSection [_]
{:id :intro
Expand Down Expand Up @@ -396,6 +399,108 @@
(apply conj [:main]
(map (partial Section data) (:account/html.account.sections config)))})

(defc Option [labels selected-value value]
[:option {:value value :selected (= selected-value value)}
(get labels value)])

(defmethod Section ::account/username [{:keys [user]} _]
[:span.username (:user/username user)])

(defmethod Section ::account/account-link
[{:keys [user i18n] {:account/keys [account-uri]} :config} _]
[:a {:href account-uri :title (:account/account-details i18n)}
(:user/username user)])

(defmethod Section ::account/heading [{:keys [i18n]} _]
[:h3 (:account/account i18n)])

(defmethod Section ::account/name [{:keys [user i18n]} _]
[:.field
[:label {:for :name} (:account/name i18n)]
[:input {:id :name :name :name :value (:user/name user)}]])

(defmethod Section ::account/pronouns [{:keys [user i18n]} _]
[:.field
[:label {:for :pronouns} (:account/pronouns i18n)]
[:input {:id :pronouns
:name :pronouns
:value (:pronouns (:user/preferences user))
:placeholder (:account/pronouns-example i18n)}]])

(defmethod Section ::account/lang [{:keys [i18n lang-names supported-langs user]} _]
(when (> (count supported-langs) 1)
[:.field
[:label {:for :lang} (:account/preferred-language i18n)]
[:select {:id :lang :name :lang}
(map (fn [k]
[:option {:selected (= k (:user/lang user)) :value k}
(get lang-names k (name k))])
(sort-by name (seq supported-langs)))]]))

(defmethod Section ::account/timezone [{:keys [config i18n user]} _]
(let [options (:account/timezone-options config)
;; TODO proper localization...
labels (map #(string/replace % "_" " ") options)
tz (:timezone (:user/preferences user))]
[:.field
[:label {:for :timezone} (:account/timezone i18n)]
[:select {:id :timezone :name :timezone}
(map (partial Option (zipmap options labels) tz) options)]]))

(defn- ua->browser [ua]
(when ua
(let [normalized (string/lower-case ua)]
(cond
(re-find #"firefox" normalized) "Firefox"
(re-find #"chrome" normalized) "Google Chrome"
(re-find #"safari" normalized) "Safari"
:default "Unknown browser"))))

(defn- ua->os [ua]
(when ua
(let [normalized (string/lower-case ua)]
(cond
(re-find #"linux" normalized) "Linux"
(re-find #"macintosh" normalized) "Mac"
(re-find #"windows" normalized) "Windows"
:default "Unknown OS"))))

(defmethod Section ::account/sessions [{:keys [i18n session user]} _]
(let [date-fmt (SimpleDateFormat. (:account/date-format-default i18n))]
[:section
[:h3 (:account/your-sessions i18n)]
[:.flex.col
(map (fn [{:as user-session
{:keys [user-agent remote-addr]} :session/data
:thing/keys [created-at updated-at]}]
(if (= (:db/id session) (:db/id user-session))
;; Current session.
[:div.user-session
[:div
(when user-agent
[:div (ua->browser user-agent) " | " (ua->os user-agent)])
(when remote-addr
[:div remote-addr])
[:div (i18n/t i18n [:account/logged-in-at (.format date-fmt created-at)])]
(when updated-at
[:div (i18n/t i18n [:account/last-active-at (.format date-fmt updated-at)])])]
[:div [:span.instruct (:account/this-session i18n)]]]
;; Sessions on other devices.
[:form.user-session {:method :post}
[:input {:type :hidden :name :dbid :value (:db/id user-session)}]
[:div
(when user-agent
[:div (ua->browser user-agent) " | " (ua->os user-agent)])
(when remote-addr
[:div remote-addr])
[:div "Logged in at " (.format date-fmt created-at)]
(when updated-at
[:div "Last active at " (.format date-fmt updated-at)])]
[:div
[:button {:type :submit :name :action :value "delete-session"}
(:auth/logout i18n)]]]))
(:user/sessions user))]]))

(defmethod Section ::email/settings-link
[{:keys [i18n] {:email/keys [settings-uri]} :config} _]
[:a {:href settings-uri :title (:email/email-settings i18n)}
Expand Down Expand Up @@ -425,7 +530,6 @@
(map (fn [{:keys [email/address
email/confirmed-at
email/primary?
thing/created-at
db/id]}]
[:form.flex.row {:method :post :role :listitem}
(anti-forgery-token-field)
Expand Down Expand Up @@ -491,6 +595,7 @@
(defc EmailPage
[{:as data :keys [config i18n]}]
{:extends SettingsPage
:key :user
:query '[:db/id :user/username {:user/emails [* :thing/created-at]}]}
{:title (:email/email i18n)
:content
Expand Down Expand Up @@ -591,6 +696,34 @@
[:button {:type :submit :name :submit :value "logout"}
(:auth/logout i18n)]])

(defmethod Section ::account/password [{:keys [i18n hook config]} _]
[:<>
[:p.instruct (:account/leave-passwords-blank i18n)]
[:.field
[:label {:for :password} (:auth/password i18n)]
[:input {:id :password
:type :password
:name :password
:maxlength (:auth/max-password-length config)}]]
[:.field
[:label {:for :password-confirmation} (:auth/password-confirmation i18n)]
[:input {:id :password-confirmation
:type :password
:name :password-confirmation
:maxlength (:auth/max-password-length config)}]]
(hook ::html.password-guidelines
[:p.instruct
(i18n/t i18n [:auth/password-must-be-between
(:auth/min-password-length config)
(:auth/max-password-length config)])])])

(defmethod Section ::account/account-form
[{:as data :keys [config ring/anti-forgery-token-field]} _]
(apply conj [:form.flex.col {:method :post}]
(when anti-forgery-token-field
(anti-forgery-token-field))
(map (partial Section data) (:account/html.account.form config))))

(defmethod Section ::account/logout-form [data _]
(LogoutForm data))

Expand All @@ -613,50 +746,52 @@

(defc SignupPage
[{:as data
:keys [config error hook i18n invitation rtl? dir ring/params ring/anti-forgery-token-field]
[valid? error-key] :validation}]
[:html {:lang (:field/lang data) :dir dir}
[:head
[:meta {:content-type "utf-8"}]
(hook ::html.title [:title (str (:signup/signup i18n) " | Bread")])
(->> (auth/LoginStyle data) (hook ::auth/html.stylesheet) (hook ::html.signup.stylesheet))
(->> [:<>] (hook ::auth/html.head) (hook ::html.signup.head))]
[:body
(cond
(and (:signup/invite-only? config) (not (:code params)))
[:main
[:form.flex.col
(anti-forgery-token-field)
(hook ::html.signup-heading [:h1 (:signup/signup i18n)])
[:p (:signup/site-invite-only i18n)]]]

(and (:signup/invite-only? config) (not invitation))
[:main
[:form.flex.col
(anti-forgery-token-field)
(hook ::html.signup-heading [:h1 (:signup/signup i18n)])
[:p (:signup/invitation-invalid i18n)]]]

:default
[:main
[:form.flex.col {:name :bread-signup :method :post}
(anti-forgery-token-field)
(hook ::html.signup-heading [:h1 (:signup/signup i18n)])
(hook ::html.enter-username
[:p.instruct (:signup/please-choose-username-password i18n)])
(Field :username :label (:auth/username i18n) :value (:username params))
(Field :password
:type :password
:label (:auth/password i18n)
:input-attrs {:maxlength (:auth/max-password-length config)})
(Field :password-confirmation
:type :password
:label (:auth/password-confirmation i18n)
:input-attrs {:maxlength (:auth/max-password-length config)})
(when error-key
(hook ::html.invalid-signup
(ErrorMessage {:message (i18n/t i18n error-key)})))
(Submit (:signup/create-account i18n))]])]])
:keys [config hook i18n invitation ring/params ring/anti-forgery-token-field]
[_valid? error-key] :validation}]
{:extends Page
:key :invitation}
{:title (:signup/signup i18n)
:content
(cond
(and (:signup/invite-only? config) (not (:code params)))
[:main
[:form.flex.col
(anti-forgery-token-field)
(hook ::html.signup-heading [:h1 (:signup/signup i18n)])
[:p (:signup/site-invite-only i18n)]]]

(and (:signup/invite-only? config) (not invitation))
[:main
[:form.flex.col
(anti-forgery-token-field)
(hook ::html.signup-heading [:h1 (:signup/signup i18n)])
[:p (:signup/invitation-invalid i18n)]]]

:default
[:main
[:form.flex.col {:name :bread-signup :method :post}
(anti-forgery-token-field)
(hook ::html.signup-heading [:h1 (:signup/signup i18n)])
(hook ::html.enter-username
[:p.instruct (:signup/please-choose-username-password i18n)])
(when error-key
(hook ::html.invalid-signup
(ErrorMessage {:message (i18n/t i18n error-key)})))
(Field :username :label (:auth/username i18n) :value (:username params))
(Field :password
:type :password
:label (:auth/password i18n)
:input-attrs {:maxlength (:auth/max-password-length config)})
(Field :password-confirmation
:type :password
:label (:auth/password-confirmation i18n)
:input-attrs {:maxlength (:auth/max-password-length config)})
(hook ::html.password-guidelines
[:p.instruct
(i18n/t i18n [:auth/password-must-be-between
(:auth/min-password-length config)
(:auth/max-password-length config)])])
(Submit (:signup/create-account i18n))]])})

(defmethod Section :flash [{:keys [session ring/flash i18n]} _]
[:<>
Expand Down
8 changes: 5 additions & 3 deletions dev/main.edn
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
:ring/session-store
{:store/type :datalog
:store/db #ig/ref :bread/db
:max-age 259200}
:max-age 259200
:secret-key #env AUTH_SECRET_KEY}
:bread/handler #ig/ref :bread/app
:bread/app
{:site {:name "Breadbox"}
:db #ig/ref :bread/db
:auth {:protected-prefixes ["/~/"]
:auth {:secret-key #env AUTH_SECRET_KEY
:protected-prefixes ["/~/"]
:login-uri "/~/login"
;:require-mfa? true
:min-password-length 4
Expand Down Expand Up @@ -58,7 +60,7 @@
;; STORE_BACKEND=mem
:store #include #join ["db-store." #or [#env STORE_BACKEND "jdbc"] ".edn"]}
:db/initial-txns
[{:invitation/code "a7d190e5-d7f4-4b92-a751-3c36add92610"
[{:invitation/code #sha-512 #join [#env AUTH_SECRET_KEY ":" "a7d190e5-d7f4-4b92-a751-3c36add92610"]
:invitation/invited-by "user.admin"
:invitation/email {:email/address "test@localhost"}
:thing/created-at #seconds-ago 3600
Expand Down
4 changes: 3 additions & 1 deletion plugins/auth/account.i18n.edn
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@
:account "Account"
:account-details "Account details"
:account-updated "Account details have been updated."
:datetime-format-default "EEE, LLL d 'at' h:mm a"
:date-format-default "EEE, LLL d"
:invalid-action "Invalid action."
:last-active-at "Last active %s"
:leave-passwords-blank "Leave password fields blank to keep your current password."
:logged-in-at "Logged in at %s"
:name "Name"
:email "Email"
:preferred-language "Preferred language"
:pronouns "Pronouns"
:pronouns-example "they/them/theirs"
:save "Save"
:session-deleted "Session deleted."
:this-session "This session"
:timezone "Timezone"
:your-sessions "Your login sessions"
}
Expand Down
3 changes: 2 additions & 1 deletion plugins/auth/auth.i18n.edn
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@
:or-enter-key-manually "Or, enter the key manually:"
:password "Password"
:password-confirmation "Password confirmation"
:passwords-must-match "Password and confirmation must match."
:password-must-be-at-least "Password must be at least %d characters long."
:password-must-be-at-most "Password must be at most %d characters long."
:password-must-be-between "Password must be %d-%d characters long."
:password-required "Password is required."
:passwords-must-match "Password and confirmation must match."
:please-scan-qr-code"Please scan the QR code to finish setting up multi-factor authentication."
:qr-code "QR code"
:too-many-attempts "You have made too many attempts to log in. Please try again later."
Expand Down
1 change: 1 addition & 0 deletions plugins/auth/signup.i18n.edn
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
:please-choose-username-password "Please choose a username and password."
:signup "Signup"
:site-invite-only "This site is invite-only."
:username-exists "Username already exists."
}
}
Loading
Loading