diff --git a/src/sb/app/account/data.cljc b/src/sb/app/account/data.cljc index 53316243..5fce8ac8 100644 --- a/src/sb/app/account/data.cljc +++ b/src/sb/app/account/data.cljc @@ -47,26 +47,16 @@ [{:keys [account-id]}] (u/timed `all (->> (q/pull '[{:member/_account [:member/roles - :member/last-visited {:member/entity [:entity/id :entity/kind :entity/title + :entity/created-at {:image/avatar [:entity/id]} {:image/background [:entity/id]}]}]}] account-id) :member/_account (map #(u/lift-key % :member/entity))))) -(q/defquery recent-ids - {:endpoint {:query true} - :prepare az/with-account-id!} - [params] - (->> (all params) - (filter :member/last-visited) - (sort-by :member/last-visited #(compare %2 %1)) - (into #{} (comp (take 8) - (map :entity/id))))) - #?(:clj (defn login! {:endpoint {:post "/login"} diff --git a/src/sb/app/board/data.cljc b/src/sb/app/board/data.cljc index 1c9a8e05..14c29b9a 100644 --- a/src/sb/app/board/data.cljc +++ b/src/sb/app/board/data.cljc @@ -102,8 +102,7 @@ ) (q/defquery show - {:prepare [(member.data/member:log-visit! :board-id) - (az/with-roles :board-id)]} + {:prepare [(az/with-roles :board-id)]} [{:keys [board-id member/roles]}] (u/timed `show (if-let [board (db/pull `[~@entity.data/entity-keys diff --git a/src/sb/app/entity/data.cljc b/src/sb/app/entity/data.cljc index 7ee857ce..23d6daa6 100644 --- a/src/sb/app/entity/data.cljc +++ b/src/sb/app/entity/data.cljc @@ -6,7 +6,9 @@ [sb.server.datalevin :as dl] [sb.schema :as sch :refer [? s- unique-uuid]] [sb.validate :as validate] - [inside-out.forms :as io])) + [inside-out.forms :as io] + [clojure.set :as set] + [sb.util :as u])) (sch/register! (merge @@ -119,14 +121,39 @@ (seq m) (conj m) (seq nils) (into (for [a nils] [:db/retract e a]))))) +(def rules + {:entity/tags (fn validate-changed-tags [roles k entity m] + (when-not (= :member (:entity/kind entity)) + (validate/validation-failed! "Only members may have tags")) + (let [tags-before (into #{} (map :tag/id) (k entity)) + tags-after (into #{} (map :tag/id) (k m)) + tags-changed (concat + (set/difference tags-before tags-after) ;; removed + (set/difference tags-after tags-before)) ;; added + admin? (:role/board-admin roles)] + (when (seq tags-changed) + (let [tag-defs (-> entity :member/entity :entity/member-tags (u/index-by :tag/id))] + (doseq [tag-id tags-changed ;; added + :let [tag (get tag-defs tag-id)] + :when (:tag/restricted? tag)] + (when (and (not admin?) (:tag/restricted? tag)) + (validate/permission-denied! "Only admins may modify restricted tags")) + (when (not tag) + (validate/validation-failed! (str "Tag " tag-id " does not exist"))))))))}) + (q/defx save-attributes! {:prepare [az/with-account-id!]} [{:keys [account-id]} e m] - (let [e (sch/wrap-id e) + (let [e (sch/wrap-id e) entity (dl/entity e) - _ (validate/assert-can-edit! account-id entity) - txs (-> (assoc m :db/id e) - retract-nils)] + roles (az/all-roles account-id entity) + _ (validate/assert-can-edit! roles) + txs (-> (assoc m :db/id e) + retract-nils)] + (doseq [k (keys m) + :let [rule (get rules k)] + :when rule] + (rule roles k entity m)) (let [parent-schema (-> (keyword (name (:entity/kind entity)) "as-map") (@sch/!malli-registry)) @@ -190,4 +217,4 @@ txs (map-indexed (fn [i x] [:db/add [:entity/id (db/get x :entity/id)] order-by i]) siblings)] (db/transact! txs) - txs)) + txs)) \ No newline at end of file diff --git a/src/sb/app/member/data.cljc b/src/sb/app/member/data.cljc index c087f421..81613047 100644 --- a/src/sb/app/member/data.cljc +++ b/src/sb/app/member/data.cljc @@ -1,17 +1,15 @@ (ns sb.app.member.data - (:require #?(:clj [java-time.api :as time]) - [re-db.api :as db] - [sb.app.entity.data :as entity.data] + (:require [sb.app.entity.data :as entity.data] [sb.authorize :as az] [sb.query :as q] [sb.schema :as sch :refer [? s-]] - [sb.server.datalevin :as dl]) + [sb.server.datalevin :as dl] + [sb.util :as u] + [re-db.api :as db]) #?(:clj (:import [java.util Date]))) (sch/register! {:roles/as-map {s- :member/as-map} - :member/last-visited (merge sch/instant - {s- 'inst?}) :member/entity+account (merge {:db/tupleAttrs [:member/entity :member/account]} sch/unique-value) :member/_entity {s- [:or @@ -63,7 +61,6 @@ (? :member/newsletter-subscription?) (? :entity/tags) (? :member/roles) - (? :member/last-visited) ;; TODO, backfill? (? :entity/created-at) @@ -96,6 +93,33 @@ (when-not (membership-id account-id entity-id) (throw (ex-info "Not a member" {:status 403}))))) +(defn member-active? [member] + (and (not (:member/inactive? member)) + (not (:entity/deleted-at member)))) + +#?(:clj + (defn can-view? [account-id entity] + (let [visibility-entity (case (:entity/kind entity) + (:board :org) entity + :project (:entity/parent entity) + :member (:member/entity entity))] + (or (:entity/public? visibility-entity) + (some-> (membership-id account-id entity) + db/entity + member-active?))))) + +(q/defquery descriptions + {:endpoint {:query true} + :prepare az/with-account-id!} + [{:as params :keys [account-id ids]}] + (u/timed `descriptions + (into [] + (comp (map (comp db/entity sch/wrap-id)) + (filter member-active?) + (map (db/pull `[~@entity.data/entity-keys + :entity/public?]))) + ids))) + (q/defquery search {:prepare [az/with-account-id!]} [{:as params :keys [account-id entity-id search-term]}] @@ -132,18 +156,6 @@ ;:entity-id [:entity/id #uuid "a1630339-64b3-3604-8110-0f22355e12be"] :search-term "matt"})) -#?(:clj - (defn member:log-visit! [entity-key] - (fn [req params] - (when-let [id (some-> (-> req :account :entity/id) - (membership-id (entity-key params)))] - (re-db.reactive/silently - (db/transact! [[:db/add id :member/last-visited - (-> (time/offset-date-time) - time/instant - Date/from)]]))) - params))) - (defn new-entity-with-membership [entity account-id roles] {:entity/id (random-uuid) :entity/kind :member diff --git a/src/sb/app/org/data.cljc b/src/sb/app/org/data.cljc index 1724feb6..5abce5bb 100644 --- a/src/sb/app/org/data.cljc +++ b/src/sb/app/org/data.cljc @@ -59,8 +59,7 @@ org-id)) (q/defquery show - {:prepare [az/with-account-id! - (member.data/member:log-visit! :org-id)]} + {:prepare [az/with-account-id!]} [{:keys [org-id]}] (q/pull `[~@entity.data/entity-keys {:entity/_parent ~entity.data/entity-keys}] diff --git a/src/sb/app/views/header.cljc b/src/sb/app/views/header.cljc index 0f662db3..0e5716dd 100644 --- a/src/sb/app/views/header.cljc +++ b/src/sb/app/views/header.cljc @@ -5,9 +5,8 @@ [sb.app.asset.ui :as asset.ui] [sb.app.chat.data :as chat.data] [sb.app.chat.ui :as chat.ui] - [sb.app.account.data :as account.data] [sb.app.entity.ui :as entity.ui] - [sb.authorize :as az] + [sb.app.member.data :as member.data] [sb.i18n :as i :refer [t]] [sb.routing :as routes] [sb.app.views.ui :as ui] @@ -16,7 +15,8 @@ [sb.util :as u] [yawn.hooks :as h] [yawn.util :as yu] - [sb.routing :as routing])) + [sb.routing :as routing] + [sb.query :as q])) #?(:cljs (defn lang-menu-content [] @@ -25,12 +25,12 @@ (p/do (i/set-locale! {:i18n/locale v}) (js/window.location.reload)))] - (map (fn [lang] - (let [selected (= lang current-locale)] - [{:selected selected - :on-click (when-not selected #(on-select lang))} - (get-in i/dict [lang :meta/lect])])) - (keys i/dict))))) + (mapv (fn [lang] + (let [selected (= lang current-locale)] + [{:selected selected + :on-click (when-not selected #(on-select lang))} + (get-in i/dict [lang :meta/lect])])) + (keys i/dict))))) (ui/defview lang [classes] [:div.inline-flex.flex-row.items-center {:class ["hover:text-txt-faded" @@ -84,12 +84,25 @@ [{:on-click #(routes/nav! 'sb.app.account-ui/logout!)} (t :tr/logout)] [{:sub? true :trigger [icons/languages "w-5 h-5"] - :children (lang-menu-content)}]]})] + :items (lang-menu-content)}]]})] [:a.btn.btn-transp.px-3.py-1.h-7 {:href (routes/path-for ['sb.app.account-ui/sign-in])} (t :tr/continue-with-email)])) (def down-arrow (icons/chevron-down:mini "ml-1 -mr-1 w-4 h-4")) +(ui/defview recents [] + (when-let [entities (-> (:value (q/use [`member.data/descriptions {:ids @routing/!recent-ids}])) + ui/use-last-some)] + (when (seq entities) + (radix/dropdown-menu + {:id :show-recents + :trigger [:button (t :tr/recent) down-arrow] + :items (into [] + (map (fn [entity] + [{:on-select #(routes/nav! (routes/entity-route entity 'ui/show) entity)} + (:entity/title entity)])) + entities)})))) + (ui/defview entity [{:as entity :keys [entity/title member/roles @@ -106,15 +119,7 @@ (into [:div.flex.gap-1] (concat children [(entity.ui/settings-button entity) - (radix/dropdown-menu - {:id :show-recents - :trigger [:button (t :tr/recent) down-arrow] - :items (map (fn [entity] - [{:on-select #(routes/nav! (routes/entity-route entity 'ui/show) entity)} - (:entity/title entity)]) - (when-let [recent-ids (account.data/recent-ids nil)] - (filterv (comp recent-ids :entity/id) - (account.data/all nil))))}) + (recents) (radix/dropdown-menu {:id :new :trigger [:button (t :tr/new) down-arrow] diff --git a/src/sb/app/views/radix.cljc b/src/sb/app/views/radix.cljc index baf6fc6c..b091ce66 100644 --- a/src/sb/app/views/radix.cljc +++ b/src/sb/app/views/radix.cljc @@ -32,7 +32,7 @@ :sideOffset 0})) (defn menu-item-classes [selected?] - (str "block px-3 rounded mx-1 relative hover:outline-0 data-[highlighted]:bg-gray-100 cursor-default " + (str "flex items-center px-3 h-8 rounded mx-1 relative hover:outline-0 data-[highlighted]:bg-gray-100 cursor-default " (if selected? "text-txt/50 " (str "hover:bg-primary/5 " diff --git a/src/sb/app/views/ui.cljs b/src/sb/app/views/ui.cljs index cd93b47e..1aab93a1 100644 --- a/src/sb/app/views/ui.cljs +++ b/src/sb/app/views/ui.cljs @@ -143,6 +143,15 @@ [image-url (@!loaded image-url)]) [@!last-loaded (not= @!last-loaded image-url)])) +(defn use-last-some [value] + (let [!last-value (h/use-state value)] + (h/use-effect (fn [] + (when (and (some? value) (not= value @!last-value)) + (reset! !last-value value)) + nil) + (h/use-deps value)) + @!last-value)) + (def email-schema [:re #"^[^@]+@[^@]+$"]) (comment diff --git a/src/sb/client/local_storage.cljc b/src/sb/client/local_storage.cljc index 9dd1d499..454189d7 100644 --- a/src/sb/client/local_storage.cljc +++ b/src/sb/client/local_storage.cljc @@ -1,21 +1,35 @@ (ns sb.client.local-storage - (:require [re-db.memo :as memo] + (:require [applied-science.js-interop :as j] + [re-db.memo :as memo] [re-db.reactive :as r] [re-db.sync.transit :as transit])) +#?(:cljs + (defn- set-item [k v] + {:pre [(string? k)]} + (j/call-in js/window [:localStorage :setItem] k (transit/pack v)))) + +#?(:cljs + (defn- get-item [k] + {:pre [(string? k)]} + (transit/unpack (j/call-in js/window [:localStorage :getItem] k)))) + +(comment + (get-item "foo") + (set-item "foo" 1) + (.. js/window -localStorage (getItem "foo")) + (.. js/window -localStorage (setItem "foo" "bar")) + + ) + (memo/defn-memo $local-storage - "Returns a 2-way syncing local-storage atom identified by `k` with default value" - [k default] + "Returns a 2-way syncing local-storage atom identified by `k` with default value" + [k default] #?(:cljs (let [k (str k)] - (doto (r/atom (or (-> (.-localStorage js/window) - (.getItem k) - transit/unpack) - default)) + (doto (r/atom (or (get-item k) + (doto default (->> (set-item k))))) (add-watch ::update-local-storage - (fn [_k _atom _old new] - (.setItem (.-localStorage js/window) - k - (transit/pack new)))))) + (fn [_k _atom _old v] (set-item k v))))) :clj (r/atom default))) - \ No newline at end of file + diff --git a/src/sb/routing.cljc b/src/sb/routing.cljc index 183e1b6b..795f4e5f 100644 --- a/src/sb/routing.cljc +++ b/src/sb/routing.cljc @@ -9,6 +9,7 @@ [re-db.reactive :as r] [re-db.xform :as xf] [reitit.core :as reit] + [sb.client.local-storage :as local] [sb.http :as http] [sb.query-params :as query-params] [sb.schema :as sch] @@ -69,6 +70,20 @@ (defn tag->endpoint [tag method] (get-in @!tag->endpoints [tag method])) +(defonce !recent-ids (local/$local-storage ::recently-viewed-ids ())) + +(r/redef !track-recents + (r/reaction! + (swap! !recent-ids + (fn [ids] + (->> (concat (->> @!location vals + (mapcat :match/params) + (filter (comp #{:org-id :board-id :project-id} key)) + (map (comp sch/unwrap-id val))) + ids) + distinct + (take 6)))))) + (defn aux:parse-path "Given a `path` string, returns map of {, } diff --git a/src/sb/validate.cljc b/src/sb/validate.cljc index d530d4d5..ae15b9af 100644 --- a/src/sb/validate.cljc +++ b/src/sb/validate.cljc @@ -92,26 +92,31 @@ (assert (-> (mu/optional-keys schema) (mu/assoc :entity/domain-name (mu/optional-keys :domain-name/as-map))))))) -(defn can-edit? [account-id entity-id] - (or (-> (az/scoped-roles account-id entity-id) - az/editor-role?))) - -(defn permission-denied! [] - (ex-info "Permission denied" - {:response {:status 400 - :body {:error "Permission denied" - :inside-out.forms/messages-by-path {() ["Permission denied"]}}}})) - -(defn validation-failed! [reason] - (ex-info "Validation failed" - {:response {:status 400 - :body {:error "validation failed" - :inside-out.forms/messages-by-path {() [reason]}}}})) +(defn can-edit? [account-id entity] + (-> (az/all-roles account-id entity) + az/editor-role?)) + +(defn permission-denied! [& [message]] + (let [message (or message "Permission denied")] + (ex-info message + {:response {:status 400 + :body {:error message + :inside-out.forms/messages-by-path {() [message]}}}}))) + +(defn validation-failed! [& [message]] + (let [message (or message "Validation failed")] + (ex-info message + {:response {:status 400 + :body {:error message + :inside-out.forms/messages-by-path {() [message]}}}}))) #?(:clj - (defn assert-can-edit! [account-id entity] - (when-not (can-edit? account-id entity) - (permission-denied!)))) + (defn assert-can-edit! + ([roles] + (when-not (az/editor-role? roles) + (permission-denied!))) + ([account-id entity] + (assert-can-edit! (az/all-roles account-id entity))))) (comment