diff --git a/cms/systems/bread/alpha/cms/main.cljc b/cms/systems/bread/alpha/cms/main.cljc index 8add4633..89c3521d 100644 --- a/cms/systems/bread/alpha/cms/main.cljc +++ b/cms/systems/bread/alpha/cms/main.cljc @@ -44,6 +44,7 @@ [systems.bread.alpha.plugin.rum :as rum] [systems.bread.alpha.plugin.signup :as signup] [systems.bread.alpha.plugin.account :as account] + [systems.bread.alpha.plugin.invitations :as invitations] [systems.bread.alpha.tools.util]) (:import [java.io Console] @@ -93,6 +94,10 @@ {:name :email :dispatcher/type ::email/settings=> :dispatcher/component #'rise/EmailPage}] + ["/invitations" + {:name :invitations + :dispatcher/type ::invitations/invitations=> + :dispatcher/component #'rise/InvitationsPage}] ["/edit" {:name :edit :dispatcher/type ::marx/edit=>}] @@ -370,6 +375,7 @@ [(auth/plugin (:auth app-config)) (signup/plugin (:signup app-config)) (account/plugin (:account app-config)) + (invitations/plugin (:invitation app-config)) (marx/plugin (:marx app-config)) (navigation/plugin (:navigation app-config)) (rum/plugin (:renderer app-config)) diff --git a/cms/themes/rise/systems/bread/alpha/cms/theme/rise.cljc b/cms/themes/rise/systems/bread/alpha/cms/theme/rise.cljc index f098d6c4..fdb31a92 100644 --- a/cms/themes/rise/systems/bread/alpha/cms/theme/rise.cljc +++ b/cms/themes/rise/systems/bread/alpha/cms/theme/rise.cljc @@ -5,7 +5,8 @@ [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.auth :as auth] + [systems.bread.alpha.plugin.invitations :as invitations])) (defn- IntroSection [_] {:id :intro @@ -337,7 +338,7 @@ (:email/email i18n)]) (defmethod Section ::email/heading [{:keys [i18n]} _] - [:h3 (:email/email-heading i18n)]) + [:h2 (:email/email-heading i18n)]) (defn- compare-emails [a b] (cond @@ -432,6 +433,93 @@ [:main (map (partial Section data) (:email/html.email.sections config))]}) +(defmethod Section ::invitations/invitations-link + [{:keys [config i18n]} _] + [:a {:href (:invitations/invitations-uri config)} + (:invitations/invitations i18n "Invitations")]) + +(defmethod Section ::invitations/invitations-heading [{:keys [i18n]} _] + [:h2 (:invitations/invitations i18n "Invitations")]) + +(defmethod Section ::invitations/invite-form + [{:keys [config i18n ring/params ring/anti-forgery-token-field user]} _] + [:form.flex.col {:method :post :action (:invitations/invitations-uri config)} + (anti-forgery-token-field) + (let [max-total (:invitations/max-total config) + left (when max-total (- max-total (count (:invitation/_invited-by user)))) + any-left? (or (not max-total) (not (zero? left)))] + [:<> + (when any-left? + [:h3 (:invitations/invite i18n)]) + (when max-total + [:.instruct (if any-left? + (i18n/t i18n [:invitations/total-left left]) + (:invitations/total-reached i18n))]) + (when (or (not max-total) (not (zero? left))) + [:.field + [:label {:for :email} (:email/email i18n)] + [:input {:id :email :name :email :type :email :value (:email params)}] + (Submit (:invitations/invite i18n "Invite") :name :action :value "send")])])]) + +(defn- compare-invitations [a b] + (let [redeemer-a (:invitation/redeemer a) + redeemer-b (:invitation/redeemer b)] + (cond + ;; Always list pending first (reverse chronological)... + (and redeemer-a (not redeemer-b)) 1 + (and redeemer-b (not redeemer-a)) -1 + (and (not redeemer-a) (not redeemer-b)) + (compare (:thing/updated-at b) (:thing/updated-at a)) + ;; ...and then redeemed. + :else (compare (:email/created-at a) (:email/created-at b))))) + +(defmethod Section ::invitations/invitations-list + [{:keys [config i18n user ring/anti-forgery-token-field]} _] + (let [invitations (sort compare-invitations (:invitation/_invited-by user))] + [:.flex.col + [:h3 (:invitations/your-invitations i18n)] + (if (seq invitations) + (map (fn SentInvitation [{{:email/keys [address]} :invitation/email + :keys [db/id + invitation/code + invitation/redeemer + thing/updated-at]}] + (if redeemer + [:.flex.row + [:label address] + [:small (i18n/t i18n [:invitations/accepted-at updated-at])]] + [:form {:method :post :action (:invitations/invitations-uri config)} + (anti-forgery-token-field) + [:.field.flex.row {:data-code code} + [:input {:type :hidden :name :id :value id}] + [:.flex.col.tight + [:label address] + [:small (i18n/t i18n [:invitations/sent-at updated-at])]] + [:span.spacer] + [:button {:type :submit :name :action :value :resend} + (:invitations/resend i18n)] + [:button {:type :submit :name :action :value :revoke} + (:invitations/revoke i18n)]]])) + invitations) + [:p.instruct (:invitations/no-invitations-body i18n)])])) + +(defc InvitationsPage + [{:as data :keys [i18n ring/anti-forgery-token-field]}] + {:extends SettingsPage + :key :user + :query '[:db/id + :user/username + {:invitation/_invited-by [* :thing/created-at + {:invitation/email [*]}]}]} + {:title (:invitations/invitations i18n) + :content + [:main.flex.col + ;; TODO + (map (partial Section data) [::invitations/invitations-heading + :flash + ::invitations/invite-form + ::invitations/invitations-list])]}) + (defc LogoutForm [{:keys [config i18n ring/anti-forgery-token-field]}] {:doc "Standard logout form for the account page."} [:form.logout-form {:method :post :action (:auth/login-uri config)} diff --git a/dev/main.edn b/dev/main.edn index 0bf88919..7e1720cb 100644 --- a/dev/main.edn +++ b/dev/main.edn @@ -57,7 +57,10 @@ :store #include #join ["db-store." #or [#env STORE_BACKEND "jdbc"] ".edn"]} :db/initial-txns [{:invitation/code "a7d190e5-d7f4-4b92-a751-3c36add92610" - :invitation/invited-by "user.admin"} + :invitation/invited-by "user.admin" + :invitation/email {:email/address "test@localhost"} + :thing/created-at #seconds-ago 3600 + :thing/updated-at #seconds-ago 0} {:db/id "user.admin" :user/username "bread" :user/name "Bread User" diff --git a/plugins/auth/auth.i18n.edn b/plugins/auth/auth.i18n.edn index 86dd374a..c64668ae 100644 --- a/plugins/auth/auth.i18n.edn +++ b/plugins/auth/auth.i18n.edn @@ -29,8 +29,8 @@ :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." - :password-must-be-at-most "Password must be at most %d characters." + :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-required "Password is required." :please-scan-qr-code"Please scan the QR code to finish setting up multi-factor authentication." :qr-code "QR code" diff --git a/plugins/auth/invitations.i18n.edn b/plugins/auth/invitations.i18n.edn new file mode 100644 index 00000000..5ccb4fa4 --- /dev/null +++ b/plugins/auth/invitations.i18n.edn @@ -0,0 +1,24 @@ +{ + :en + #:invitations{ + :accepted-at "Accepted %s" + :invitation-email-body "%s invited you to %s! Go here to accept:\n\n%s" + :invitation-email-subject "You've been invited to %s" + :invitation-invalid "This invitation is invalid or has already been redeemed." + :invitation-pending "Invitation pending" + :invitation-resent "Invitation resent." + :invitation-revoked "Invitation revoked." + :invitation-sent "Invitation sent." + :invitations "Invitations" + :invite "Invite" + :no-invitations-body "You haven't sent any invitations yet." + :resend "Resend" + :revoke "Revoke" + :sending-too-many "You are sending too many invitations. Please wait." + :sent-at "Sent %s" + :signup "Signup" + :total-left "You have %s invitation(s) left." + :total-reached "You have no invitations left." + :your-invitations "Your invitations" + } + } diff --git a/plugins/auth/signup.i18n.edn b/plugins/auth/signup.i18n.edn index 79016157..f2a60808 100644 --- a/plugins/auth/signup.i18n.edn +++ b/plugins/auth/signup.i18n.edn @@ -2,11 +2,11 @@ :en #:signup{ :all-fields-required "All fields are required." + :create-account "Create account" + :email-exists "Email already exists." :error "Account locked" - :invitation-invalid "This invitation is invalid or has already been redeemed." :please-choose-username-password "Please choose a username and password." :signup "Signup" :site-invite-only "This site is invite-only." - :create-account "Create account" } } diff --git a/plugins/auth/systems/bread/alpha/plugin/account.cljc b/plugins/auth/systems/bread/alpha/plugin/account.cljc index 0d1f09a9..2ee437d1 100644 --- a/plugins/auth/systems/bread/alpha/plugin/account.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/account.cljc @@ -12,6 +12,8 @@ [systems.bread.alpha.i18n :as i18n] [systems.bread.alpha.ring :as ring] [systems.bread.alpha.plugin.auth :as auth] + [systems.bread.alpha.plugin.signup :as signup] + [systems.bread.alpha.plugin.invitations :as invitations] [systems.bread.alpha.plugin.email :as email]) (:import [java.text SimpleDateFormat])) @@ -257,6 +259,7 @@ "America/New_York"] html-account-header [::account-link ::email/settings-link + ::invitations/invitations-link :spacer ::logout-form] html-account-form [::heading diff --git a/plugins/auth/systems/bread/alpha/plugin/invitations.cljc b/plugins/auth/systems/bread/alpha/plugin/invitations.cljc new file mode 100644 index 00000000..d8d4e402 --- /dev/null +++ b/plugins/auth/systems/bread/alpha/plugin/invitations.cljc @@ -0,0 +1,301 @@ +(ns systems.bread.alpha.plugin.invitations + (:require + [clojure.edn :as edn] + [clojure.java.io :as io] + [clojure.string :as string] + [crypto.random :as random] + [taoensso.timbre :as log] + + [systems.bread.alpha.core :as bread] + [systems.bread.alpha.database :as db] + [systems.bread.alpha.i18n :as i18n] + [systems.bread.alpha.internal.time :as t] + [systems.bread.alpha.plugin.email :as email] + [systems.bread.alpha.ring :as ring]) + (:import + [java.net URLEncoder])) + +(defn- ->int [x] + (try (Integer. x) (catch java.lang.NumberFormatException _ nil))) + +(defmethod bread/expand ::validate-invitation + [{{:invitations/keys [max-window-count + max-window-minutes + max-total]} :config + {:keys [id action email]} :params} + {:as data + :keys [existing-email user] + {invitations :invitation/_invited-by} :user}] + (let [action (keyword action) + total-reached? (when max-total + (>= (count invitations) max-total)) + window-cutoff (t/minutes-ago max-window-minutes) + recent-count (count (filter (fn [{:keys [thing/updated-at]}] + (.after updated-at window-cutoff)) + invitations)) + new-invite? (= :send action) + resending? (= :resend action) + revoking? (= :revoke action) + valid-email? (when new-invite? (string/includes? email "@")) + pending-ids (->> invitations + (filter (complement :invitation/redeemer)) + (map :db/id) + set) + id (->int id) + ;; To avoid an extra query, we just loop through the user's pending + ;; invitations to check whether the given id is one the user is + ;; authorized to revoke. + pending? (contains? pending-ids id) + invitation-invalid? (and (or resending? revoking?) + (or (not id) (not pending?))) + rate-limited? (and (or new-invite? resending?) + (>= recent-count max-window-count)) + error-key (cond + (and new-invite? existing-email) :signup/email-exists + (and new-invite? (not valid-email?)) :email/invalid-email + (and new-invite? total-reached?) :total-reached + rate-limited? :invitations/sending-too-many + invitation-invalid? :invitations/invitation-invalid) + valid? (not error-key)] + [valid? error-key])) + +(comment + (def $effect + {:effect/name ::email + :effect/description "Send an invitation email." + :code "asdfqwerty" + :params + {:email "test@tamayo.email", + :action "send"}}) + + (:code $effect) + (:signup/signup-uri (:config $data)) + (:user $data) + (:ring/scheme $data) + (:ring/server-name $data) + (:ring/server-port $data) + (invitation-email-subject $data) + (invitation-email-body + (assoc $data + :link + (invitation-link (assoc $data :invitation/code (:code $effect))))) + ,) + +(defn invitation-link [{:keys [config + invitation/code + ring/scheme + ring/server-name + ring/server-port]}] + (format "%s://%s%s%s?code=%s" + (name scheme) server-name (when server-port (str ":" server-port)) + (:signup/signup-uri config) (URLEncoder/encode code))) + +(defn invitation-email-subject [{:keys [config i18n ring/server-name]}] + (let [site-name (:site/name config server-name)] + (i18n/t i18n [:invitations/invitation-email-subject site-name]))) + +(defn invitation-email-body [{:keys [config i18n link ring/server-name user]}] + (let [from-name (or (:user/name user) (:user/username user)) + site-name (:site/name config server-name)] + (i18n/t i18n [:invitations/invitation-email-body from-name site-name link]))) + +(defmethod bread/effect ::invitation-email + [{:as effect :keys [code from to]} + {:as data :keys [config hook]}] + (let [from (or from (:email/smtp-from-email config)) + link (invitation-link (assoc data :invitation/code code)) + message (hook ::invitation-message + {:from from + :to to + :subject (invitation-email-subject data) + :body (invitation-email-body (assoc data :link link))})] + {:effects + [{:effect/name ::email/send! + :effect/description "Send invitation email." + :message message}]})) + +(defmethod bread/effect [::invite :send] send-invitation + [{:keys [conn params]} + {:as data + :keys [config i18n existing-email user] + [valid? error-key] :validation}] + (if valid? + (let [email (:email params) + code (random/url-part 32) + now (t/now) + invitation-tx {:invitation/code code + :invitation/invited-by (:db/id user) + :invitation/email {:email/address email + :thing/created-at now + :thing/updated-at now} + :thing/created-at now + :thing/updated-at now} + email-effect (when valid? + {:effect/name ::invitation-email + :effect/description "Send an invitation email." + :code code + :to email})] + (try + (log/info "sending invitation email" {:email email + :invitation/invited-by (:db/id user)}) + (db/transact conn [invitation-tx]) + {:effects [email-effect] + :flash {:success-key :invitations/invitation-sent}} + (catch clojure.lang.ExceptionInfo e + (log/error e) + {:flash {:error-key :email/unexpected-error}}))) + {:flash {:error-key error-key}})) + +(defmethod bread/effect [::invite :resend] resend-invitation + [{:keys [conn params]} + {:as data + :keys [config i18n user] + [valid? error-key] :validation}] + (if valid? + (let [id (->int (:id params)) + code (random/url-part 32) + now (t/now) + invitation-tx {:db/id id + :invitation/code code + :thing/updated-at now} + invitation (first (filter #(= id (:db/id %)) (:invitation/_invited-by user))) + to (:email/address (:invitation/email invitation)) + email-effect {:effect/name ::invitation-email + :effect/description "Resend invitation with a new code." + :code code + :to to}] + (try + (log/info "resending invitation email" {:email to + :invitation/invited-by (:db/id user)}) + (db/transact conn [invitation-tx]) + {:effects [email-effect] + :flash {:success-key :invitations/invitation-resent}} + (catch clojure.lang.ExceptionInfo e + (log/error e) + {:flash {:error-key :email/unexpected-error}}))) + {:flash {:error-key error-key}})) + +(defmethod bread/effect [::invite :revoke] resend-invitation + [{:keys [conn params]} + {:as data :keys [user] [valid? error-key] :validation}] + (if valid? + (let [id (->int (:id params)) + invitation (first (filter #(= id (:db/id %)) (:invitation/_invited-by user))) + email-id (:db/id (:invitation/email invitation))] + (try + (db/transact conn [[:db/retractEntity id] + [:db/retractEntity email-id]]) + {:flash {:success-key :invitations/invitation-revoked}} + (catch clojure.lang.ExceptionInfo e + (log/error e) + {:flash {:error-key :email/unexpected-error}}))) + {:flash {:error-key error-key}})) + +(defmethod bread/dispatch ::invitations=> + [{:keys [::bread/dispatcher params request-method server-name] + {:keys [user]} :session + :as req}] + "Invitations page in the account section" + (let [post? (= :post request-method) + action (when (seq (:action params)) (keyword (:action params))) + pull (conj (:dispatcher/pull dispatcher) :user/name) + query {:find [(list 'pull '?e pull) '.] + :in '[$ ?e]} + user-expansion {:expansion/key (:dispatcher/key dispatcher :user) + :expansion/name ::db/query + :expansion/description "Query for user emails." + :expansion/db (db/database req) + :expansion/args [query (:db/id user)]} + email-expansion {:expansion/key :existing-email + :expansion/name ::db/query + :expansion/description "Query for conflicting emails." + :expansion/db (db/database req) + :expansion/args ['{:find [?e .] + :in [$ ?email] + :where [[?e :email/address ?email]]} + (:email params)]} + validation {:expansion/key :validation + :expansion/name ::validate-invitation + :params params + :config (::bread/config req)}] + (if post? + {:expansions [user-expansion email-expansion validation] + :effects + [{:effect/name [::invite action] + :effect/description "Email an invitation, pending validation." + :effect/key action + :params params + :conn (db/connection req)}] + :hooks + {::bread/render + [{:action/name ::ring/effect-redirect + :action/description "Redirect to invitations page." + :effect/key action + :to (bread/config req :invitations/invitations-uri)}]}} + {:expansions [user-expansion]}))) + +(def + ^{:doc "Schema for invitations"} + schema + (with-meta + [{:db/id "migration.invitations" + :migration/key :bread.migration/invitations + :migration/description "Migration for invitations to sign up"} + {:db/ident :invitation/email + :attr/label "Invitation email" + :db/doc "Email this invitation was sent to, if any" + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :attr/migration "migration.invitation"} + {:db/ident :invitation/redeemer + :attr/label "Redeeming user" + :db/doc "User who redeemed this invitation, if any" + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :attr/migration "migration.invitation"} + {:db/ident :invitation/code + :attr/label "Invitation code" + :db/doc "Secure ID for this invitation" + :attr/sensitive? true + :db/unique :db.unique/identity + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :attr/migration "migration.invitation"} + {:db/ident :invitation/invited-by + :attr/label "Invited by" + :db/doc "User who created this invitation" + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :attr/migration "migration.invitation"}] + + {:type :bread/migration + :migration/dependencies #{:bread.migration/migrations + :bread.migration/things + :bread.migration/users + :bread.migration/authentication}})) + +(defn plugin + ([] + (plugin {})) + ([{:keys [invitations-uri + max-window-count + max-window-minutes + max-total] + :or {invitations-uri "/~/invitations" + max-window-count 10 + max-window-minutes 5}}] + {:hooks + {::db/migrations + [{:action/name ::db/add-schema-migration + :action/description + "Add schema for invitations to the sequence of migrations to be run." + :schema-txs schema}] + ::i18n/global-strings + [{:action/name ::i18n/merge-global-strings + :action/description "Merge strings for signup into global strings." + :strings (edn/read-string (slurp (io/resource "invitations.i18n.edn")))}]} + :config + #:invitations{:invitations-uri invitations-uri + :max-window-count max-window-count + :max-window-minutes max-window-minutes + :max-total max-total}})) diff --git a/plugins/auth/systems/bread/alpha/plugin/signup.cljc b/plugins/auth/systems/bread/alpha/plugin/signup.cljc index 27b2077d..2eb6dd52 100644 --- a/plugins/auth/systems/bread/alpha/plugin/signup.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/signup.cljc @@ -3,15 +3,21 @@ [buddy.hashers :as hashers] [clojure.edn :as edn] [clojure.java.io :as io] + [clojure.string :as string] [crypto.random :as random] [one-time.core :as ot] + [taoensso.timbre :as log] [systems.bread.alpha.core :as bread] - [systems.bread.alpha.component :refer [defc]] + [systems.bread.alpha.component :refer [defc] :as component] [systems.bread.alpha.database :as db] [systems.bread.alpha.i18n :as i18n] + [systems.bread.alpha.ring :as ring] [systems.bread.alpha.internal.time :as t] - [systems.bread.alpha.plugin.auth :as auth])) + [systems.bread.alpha.plugin.auth :as auth] + [systems.bread.alpha.plugin.email :as email]) + (:import + [java.net URLEncoder])) (defmethod bread/expand ::validate [{{:auth/keys [min-password-length max-password-length] @@ -43,22 +49,28 @@ [{:keys [conn user]} {:keys [invitation] [valid? _] :validation}] (when valid? (if invitation - {:effects [{:effect/name ::db/transact - :conn conn - :effect/description "Redeem invitation and create user." - :txs [{:invitation/code (:invitation/code invitation) - :invitation/redeemer user}]}]} + (let [email (when-let [email (:invitation/email invitation)] + (assoc email :email/confirmed-at (t/now))) + user (if email + (assoc user :user/emails [email]) + user)] + {:effects [{:effect/name ::db/transact + :conn conn + :effect/description "Redeem invitation and create user." + :txs [{:invitation/code (:invitation/code invitation) + :invitation/redeemer user}]}]}) {:effects [{:effect/name ::db/transact :conn conn :effect/description "Create user" :txs [user]}]}))) (defmethod bread/action ::redirect - [{:as res {[valid? _] :validation} ::bread/data} _ _] + [{:as res {[valid? _] :validation} ::bread/data} {:keys [to]} _] (if valid? - (-> res - (assoc :status 302) - (assoc-in [:headers "Location"] (bread/config res :auth/login-uri))) + (let [to (or to (bread/config res :auth/login-uri))] + (-> res + (assoc :status 302) + (assoc-in [:headers "Location"] to))) res)) (defmethod bread/dispatch ::signup=> @@ -71,7 +83,8 @@ :expansion/key :invitation :expansion/db (db/database req) :expansion/args - ['{:find [(pull ?e [:invitation/code]) .] + ['{:find [(pull ?e [:invitation/code + {:invitation/email [*]}]) .] :in [$ ?code] :where [[?e :invitation/code ?code] (not [?e :invitation/redeemer])]} @@ -119,46 +132,6 @@ [{:action/name ::redirect :action/description "Redirect to login"}]}})))) -(def - ^{:doc "Schema for invitations"} - schema - (with-meta - [{:db/id "migration.invitations" - :migration/key :bread.migration/invitations - :migration/description "Migration for invitations to sign up"} - {:db/ident :invitation/email - :attr/label "Invitation email" - :db/doc "Email this invitation was sent to, if any" - :db/valueType :db.type/ref - :db/cardinality :db.cardinality/one - :attr/migration "migration.invitation"} - {:db/ident :invitation/redeemer - :attr/label "Redeeming user" - :db/doc "User who redeemed this invitation, if any" - :db/valueType :db.type/ref - :db/cardinality :db.cardinality/one - :attr/migration "migration.invitation"} - {:db/ident :invitation/code - :attr/label "Invitation code" - :db/doc "Secure ID for this invitation" - :attr/sensitive? true - :db/unique :db.unique/identity - :db/valueType :db.type/string - :db/cardinality :db.cardinality/one - :attr/migration "migration.invitation"} - {:db/ident :invitation/invited-by - :attr/label "Invited by" - :db/doc "User who created this invitation" - :db/valueType :db.type/ref - :db/cardinality :db.cardinality/one - :attr/migration "migration.invitation"}] - - {:type :bread/migration - :migration/dependencies #{:bread.migration/migrations - :bread.migration/things - :bread.migration/users - :bread.migration/authentication}})) - (defmethod bread/action ::protected-route? [{:as req :keys [uri]} _ [protected?]] (and protected? (not= (bread/config req :signup/signup-uri) uri))) @@ -166,18 +139,16 @@ (defn plugin ([] (plugin {})) - ([{:keys [;; TODO email as a normal hook - invite-only? invitation-expiration-seconds signup-uri] + ([{:keys [invite-only? + invitation-expiration-seconds + invitations-uri + signup-uri] :or {invite-only? false invitation-expiration-seconds (* 72 60 60) + invitations-uri "/~/invitations" signup-uri "/_/signup"}}] {:hooks - {::db/migrations - [{:action/name ::db/add-schema-migration - :action/description - "Add schema for invitations to the sequence of migrations to be run." - :schema-txs schema}] - ::auth/protected-route? + {::auth/protected-route? [{:action/name ::protected-route? :action/description "Exclude signup-uri from protected routes"}] ::i18n/global-strings diff --git a/plugins/email/systems/bread/alpha/plugin/email.cljc b/plugins/email/systems/bread/alpha/plugin/email.cljc index 81dd9146..6ecbf222 100644 --- a/plugins/email/systems/bread/alpha/plugin/email.cljc +++ b/plugins/email/systems/bread/alpha/plugin/email.cljc @@ -78,7 +78,8 @@ (let [from (or from (:email/smtp-from-email config)) link-uri (format "%s://%s%s%s?code=%s&email=%s" (name scheme) server-name (when server-port (str ":" server-port)) - "/_/confirm-email" (URLEncoder/encode code) (URLEncoder/encode to)) + (:email/confirm-uri config) + (URLEncoder/encode code) (URLEncoder/encode to)) site-name (or (:site/name config) server-name) subject (format (:email/confirmation-email-subject i18n) site-name) body (format (:email/confirmation-email-body i18n) link-uri)] @@ -164,7 +165,7 @@ pull (:dispatcher/pull dispatcher) query {:find [(list 'pull '?e pull) '.] :in '[$ ?e]} - expansion {:expansion/key :user + expansion {:expansion/key (:dispatcher/key dispatcher :user) :expansion/name ::db/query :expansion/description "Query for user emails." :expansion/db (db/database req) diff --git a/resources/rise/rise/css/base.css b/resources/rise/rise/css/base.css index e1a69e80..ee15f215 100644 --- a/resources/rise/rise/css/base.css +++ b/resources/rise/rise/css/base.css @@ -19,12 +19,12 @@ --color-theme-dark-text-body: hsl(300, 80%, 95%); --color-theme-dark-text-emphasis: hsl(300.7, 66%, 65.3%); --color-theme-dark-stroke-emphasis: hsl(258.6, 100%, 74.7%); - --color-theme-dark-stroke-secondary: hsl(300, 75%, 12.5%); + --color-theme-dark-stroke-secondary: hsl(267.1, 36%, 33.7%); --color-theme-dark-stroke-tertiary: hsl(300, 17.8%, 17.6%); - --color-theme-dark-text-success: hsl(326, 68.3%, 62.9%); - --color-theme-dark-stroke-success: hsl(314.9, 52.7%, 46.5%); + --color-theme-dark-text-success: hsl(244.8, 100%, 75.3%); + --color-theme-dark-stroke-success: hsl(255.7, 72%, 40.6%); --color-theme-dark-text-error: hsl(326, 68.3%, 62.9%); - --color-theme-dark-stroke-error: hsl(315, 32.8%, 23.9%); + --color-theme-dark-stroke-error: hsl(315.2, 49.7%, 31.2%); --color-theme-dark-text-secondary: hsl(256, 44%, 73.5%); --color-theme-dark-bg: hsl(264, 41.7%, 4.7%); @@ -122,7 +122,7 @@ nav, header, main, article, section, footer, aside { nav { align-items: center; padding: var(--gap-standard); - border-bottom: var(--stroke-width) var(--stroke-style-emphasis) var(--color-stroke-emphasis); + border-bottom: var(--stroke-width) var(--stroke-style-emphasis) var(--color-stroke-secondary); } main { width: var(--content-width); @@ -267,7 +267,7 @@ button:hover { .success { font-weight: var(--weight-heavy); color: var(--color-text-success); - border: var(--border-width) var(--stroke-style-emphasis) var(--color-stroke-success); + border: var(--stroke-width) var(--stroke-style-emphasis) var(--color-stroke-success); padding: var(--gap-standard); } .error { diff --git a/src/systems/bread/alpha/i18n.cljc b/src/systems/bread/alpha/i18n.cljc index 30f25a95..2f3d50b8 100644 --- a/src/systems/bread/alpha/i18n.cljc +++ b/src/systems/bread/alpha/i18n.cljc @@ -65,9 +65,9 @@ (defn t [i18n k] "Translates k into its value in the given i18n map. If k is a sequence, treats (first k) as i18n key and (rest k) as args to format." - (if (seq? k) + (if (sequential? k) (let [[k & args] k] - (apply format (get i18n k) args)) + (when-let [s (get i18n k)] (apply format s args))) (get i18n k))) (defn lang @@ -120,12 +120,6 @@ (into {}) (bread/hook req ::strings)))) -(defn t - "Query the database for the translatable string represented by keyword k." - [app k] - {:pre [(keyword? k)]} - (k (strings app))) - (defn translatable-binding? "Takes a query binding vector and returns the binding itself if it is translatable, otherwise nil."