diff --git a/.expo/packager-info.json b/.expo/packager-info.json deleted file mode 100644 index 1938f58c8..000000000 --- a/.expo/packager-info.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "expoServerPort": 19000, - "packagerPort": 19001, - "packagerPid": 28823, - "expoServerNgrokUrl": "https://a2-dgb.larrychristensen.orcpub.exp.direct", - "packagerNgrokUrl": "https://packager.a2-dgb.larrychristensen.orcpub.exp.direct", - "ngrokPid": 28936 -} \ No newline at end of file diff --git a/.expo/settings.json b/.expo/settings.json deleted file mode 100644 index 310d971b0..000000000 --- a/.expo/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "hostType": "tunnel", - "lanType": "ip", - "dev": true, - "strict": false, - "minify": false, - "urlType": "exp", - "urlRandomness": "a2-dgb" -} \ No newline at end of file diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 74320d695..7800a702d 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -4,11 +4,11 @@ name: Docker Releases on: push: tags: - - 'v*' # e.g. v1.0, v2.3.4 + - '*' # e.g. 1.0, 2.3.4.4 workflow_dispatch: inputs: tag: - description: "Tag name to build (e.g., v1.2.3)" + description: "Tag name to build (e.g., 1.2.3.4)" required: true jobs: @@ -23,25 +23,26 @@ jobs: id: get_version run: | if [[ -n "${{ github.event.inputs.tag }}" ]]; then - ref="${{ github.event.inputs.tag }}" + VERSION="${{ github.event.inputs.tag }}" else - ref="${GITHUB_REF}" + VERSION="${GITHUB_REF#refs/tags/}" fi - echo "VERSION1=${ref#refs/*/}" >> $GITHUB_OUTPUT - echo "VERSION2=${ref#refs/*/v}" >> $GITHUB_OUTPUT + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT # -------------------------------------------------------------------- # Checkout the repo (HTTPS – no SSH key needed) # -------------------------------------------------------------------- - name: Checkout uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.tag && format('refs/tags/{0}', github.event.inputs.tag) || github.ref }} # -------------------------------------------------------------------- # Update version/date in src/cljs/orcpub/ver.cljc # -------------------------------------------------------------------- - name: Update src/cljs/orcpub/ver.cljc run: | - sed -i 's/defn version \[\] ".*"/defn version [] "${{ steps.get_version.outputs.VERSION1 }}"/' src/cljs/orcpub/ver.cljc + sed -i 's/defn version \[\] ".*"/defn version [] "${{ steps.get_version.outputs.VERSION }}"/' src/cljs/orcpub/ver.cljc sed -i 's/defn date \[\] ".*"/defn date [] "$(date +%m-%d-%Y)"/' src/cljs/orcpub/ver.cljc cat src/cljs/orcpub/ver.cljc @@ -75,14 +76,14 @@ jobs: # -------------------------------------------------------------------- # Build & push the versioned image for orcpub # -------------------------------------------------------------------- - - name: Build and push ${{ steps.get_version.outputs.VERSION2 }} orcpub + - name: Build and push ${{ steps.get_version.outputs.VERSION }} orcpub uses: docker/build-push-action@v3 with: context: . file: ./docker/orcpub/Dockerfile platforms: linux/amd64 push: true - tags: orcpub/orcpub:release-${{ steps.get_version.outputs.VERSION2 }} + tags: orcpub/orcpub:release-${{ steps.get_version.outputs.VERSION }} # -------------------------------------------------------------------- # Build & push the “latest” image for datomic @@ -99,14 +100,14 @@ jobs: # -------------------------------------------------------------------- # Build & push the versioned image for datomic # -------------------------------------------------------------------- - - name: Build and push ${{ steps.get_version.outputs.VERSION2 }} datomic + - name: Build and push ${{ steps.get_version.outputs.VERSION }} datomic uses: docker/build-push-action@v3 with: context: . file: ./docker/datomic/Dockerfile platforms: linux/amd64 push: true - tags: orcpub/datomic:release-${{ steps.get_version.outputs.VERSION2 }} + tags: orcpub/datomic:release-${{ steps.get_version.outputs.VERSION }} # -------------------------------------------------------------------- # Commit the updated version file back to *develop* @@ -118,5 +119,5 @@ jobs: git fetch --all git checkout develop || git checkout -b develop git add src/cljs/orcpub/ver.cljc - git commit -m "Update version to ${{ steps.get_version.outputs.VERSION1 }}" + git commit -m "Update version to ${{ steps.get_version.outputs.VERSION }}" git push \ No newline at end of file diff --git a/docker/datomic/Dockerfile b/docker/datomic/Dockerfile index 9629c455b..d3dda3182 100644 --- a/docker/datomic/Dockerfile +++ b/docker/datomic/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:8u242-jre +FROM eclipse-temurin:8-jre-alpine ENV DATOMIC_VERSION 0.9.5703 diff --git a/docker/orcpub/Dockerfile b/docker/orcpub/Dockerfile index b808e2f6e..8c0f1cfc5 100644 --- a/docker/orcpub/Dockerfile +++ b/docker/orcpub/Dockerfile @@ -1,5 +1,4 @@ -FROM clojure:openjdk-8-lein as builder -MAINTAINER daemonsthere@gmail.com +FROM clojure:lein as builder # Build cache layer ADD ./lib/ /root/.m2/repository/ @@ -11,8 +10,7 @@ ADD ./ /orcpub RUN printenv &&\ lein uberjar -FROM openjdk:8-jre-alpine as runner -MAINTAINER daemonsthere@gmail.com +FROM eclipse-temurin:8-jre-alpine as runner COPY --from=builder /orcpub/target/orcpub.jar /orcpub.jar diff --git a/docs/email-system.md b/docs/email-system.md new file mode 100644 index 000000000..579dc331f --- /dev/null +++ b/docs/email-system.md @@ -0,0 +1,155 @@ +# Email System + +Overview of email-related flows, schema, configuration, and behavior. + +## Configuration + +All email is sent via [postal](https://github.com/drewr/postal) using SMTP credentials from environment variables: + +| Env var | Purpose | +|---------|---------| +| `EMAIL_ACCESS_KEY` | SMTP username | +| `EMAIL_SECRET_KEY` | SMTP password | +| `EMAIL_SERVER_URL` | SMTP host | +| `EMAIL_SERVER_PORT` | SMTP port (default `587`) | +| `EMAIL_SSL` | Enable SSL (`true`/`false`) | +| `EMAIL_TLS` | Enable TLS (`true`/`false`) | +| `EMAIL_FROM_ADDRESS` | Sender address (default `no-reply@orcpub.com`) | +| `EMAIL_ERRORS_TO` | Address for error notification emails (optional) | + +Configuration is read at send-time by `email/email-cfg` (`src/clj/orcpub/email.clj`). + +## Schema + +User attributes related to email and verification (`src/clj/orcpub/db/schema.clj`): + +| Attribute | Type | Purpose | +|-----------|------|---------| +| `:orcpub.user/email` | string | Confirmed email address | +| `:orcpub.user/pending-email` | string | Requested new email (awaiting verification) | +| `:orcpub.user/verified?` | boolean | Whether the user has verified their email | +| `:orcpub.user/verification-key` | string | UUID used in verification links | +| `:orcpub.user/verification-sent` | instant | When the verification email was sent | +| `:orcpub.user/password-reset-key` | string | UUID used in password reset links | +| `:orcpub.user/password-reset-sent` | instant | When the password reset email was sent | +| `:orcpub.user/password-reset` | instant | When the password was actually reset | + +**Note:** `:orcpub.user/email` has no uniqueness constraint in the schema. Uniqueness is enforced at the application level via `email-query`. See the known issues section. + +## Flows + +### 1. Registration Verification + +**Trigger:** `POST /register` (via `routes/register`) + +1. Validate username, email, password +2. Check email/username not already taken (`email-query`, `username-query`) +3. Create user entity with `verified? false`, generate `verification-key`, set `verification-sent` +4. Send registration verification email (`email/send-verification-email`) +5. User clicks link → `GET /verify?key=...` → `routes/verify` + +**Verify behavior (registration path):** +- If already verified and no `pending-email` → redirect to success +- If `verification-sent` is nil or expired (24h) → redirect to failed +- Otherwise → set `verified? true`, redirect to success + +**Re-verify:** `GET /re-verify?email=...` (`routes/re-verify`) re-sends the verification email for unverified accounts. + +**Login gate:** Unverified users cannot log in. If the verification has expired, the login error tells them to re-register. + +**Files:** `routes.clj:register`, `routes.clj:do-verification`, `routes.clj:verify`, `email.clj:send-verification-email` + +### 2. Email Change + +**Trigger:** `PUT /user/email` (via `routes/request-email-change`, requires auth) + +1. Validate new email (format, not same as current, not already taken) +2. Check rate limit (see Rate Limiting below) +3. Store `pending-email`, generate new `verification-key`, set `verification-sent` +4. Send email-change verification to the **new** address (`email/send-email-change-verification`) +5. If send fails → full rollback (retract `pending-email`, `verification-key`, `verification-sent`), return 500 + +**Verify behavior (email-change path):** +- If expired (24h) → retract `pending-email`, `verification-key`, `verification-sent`; redirect to failed +- If `pending-email` exists → re-check email availability (race-condition guard): + - If email was claimed by another user since request → retract all pending state, redirect to failed + - Otherwise → swap `email` to `pending-email`, retract `pending-email`, `verification-key`, `verification-sent`; redirect to success +- Key is invalidated after use (retracted) — link cannot be reused + +**Free resend:** Within 1–5 minutes of the original request, resending the same email re-uses the existing `verification-key` and does not update `verification-sent` (no rolling window). See Rate Limiting. + +**Files:** `routes.clj:request-email-change`, `routes.clj:verify` (pending-email branch), `email.clj:send-email-change-verification`, `events.cljs:change-email`, `views.cljs:my-account-page` + +### 3. Password Reset + +**Trigger:** `GET /send-password-reset?email=...` (via `routes/send-password-reset`) + +1. Look up user by email +2. Generate `password-reset-key`, set `password-reset-sent` +3. Send password reset email (`email/send-reset-email`) + +**Reset behavior:** `POST /reset-password` (via `routes/reset-password`) +- Validates new password and password match +- Sets new password hash, sets `password-reset` timestamp, sets `verified? true` + +**Expiration:** `password-reset-expired?` checks if `password-reset-sent` is older than 24 hours. + +**Files:** `routes.clj:send-password-reset`, `routes.clj:do-send-password-reset`, `routes.clj:reset-password`, `email.clj:send-reset-email` + +### 4. Error Notification + +**Trigger:** Called from exception handlers (e.g., Pedestal error interceptor) + +- Sends a plaintext email with the request context and exception data +- Only sends if `EMAIL_ERRORS_TO` is set +- Uses `email/send-error-email` + +**Files:** `email.clj:send-error-email` + +## Rate Limiting (Email Change) + +Rate limiting is enforced by `routes/email-change-rate-limited?` based on `verification-sent` and whether the request is a resend (same email as `pending-email`). + +Three zones measured from `verification-sent`: + +``` +0 ──────── 1 min ──────── 5 min ──────── ∞ +│ BLOCKED │ FREE RESEND │ OPEN │ +│ (transit) │ (same email) │ (any email) │ +│ │ blocked for │ │ +│ │ diff email │ │ +``` + +- **0–1 min:** All requests blocked. Email is in transit. Client shows "Your email is on its way. You can resend in N seconds." +- **1–5 min:** Resend of same email allowed (free resend, no DB write, reuses existing key). Different email blocked. Client shows "Please wait N minutes before requesting another change." +- **5+ min:** Any request allowed. New `verification-key` generated, `verification-sent` updated. + +The 429 response includes `retry-after-secs` so the client can display a specific countdown. + +## Expiration Windows + +| Window | Duration | Function | +|--------|----------|----------| +| Verification link | 24 hours | `verification-expired?` | +| Password reset link | 24 hours | `password-reset-expired?` | +| Email change rate limit | 5 minutes | `email-change-rate-limited?` | +| Free resend grace | 1–5 minutes | `email-change-rate-limited?` + free resend branch | + +## File Map + +| File | Role | +|------|------| +| `src/clj/orcpub/email.clj` | Email templates and send functions (postal) | +| `src/clj/orcpub/routes.clj` | Server handlers: register, verify, email change, password reset | +| `src/clj/orcpub/db/schema.clj` | Datomic schema for user attributes | +| `src/cljc/orcpub/route_map.cljc` | Route definitions (shared server/client) | +| `src/cljs/orcpub/dnd/e5/events.cljs` | Re-frame events for email change UI | +| `src/cljs/orcpub/dnd/e5/views.cljs` | My Account page with email change form | +| `src/cljs/orcpub/dnd/e5/subs.cljs` | Subscriptions for pending-email, email-change state | +| `test/clj/orcpub/email_change_test.clj` | Email change tests (11 tests, datomock) | + +## Known Issues + +- **No uniqueness constraint on email in schema.** Uniqueness is enforced at the application level by `email-query` (at request time) and a race-condition guard (at verify time). A Datomic `:db.unique/value` constraint on `:orcpub.user/email` would be the proper fix but requires a data migration to handle any existing duplicates. + +- **Pending-email conflicts not checked.** Two users can simultaneously request the same new email. Both receive verification emails, but only the first to verify succeeds — the second is caught by the race-condition guard. The "loser" gets a confusing failure after clicking a valid-looking link. diff --git a/exp.json b/exp.json deleted file mode 100644 index 052401d96..000000000 --- a/exp.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "orcpub", - "description": "No description", - "slug": "orcpub", - "sdkVersion": "17.0.0", - "version": "1.0.0", - "orientation": "portrait", - "primaryColor": "#cccccc", - "privacy": "public", - "icon": "./assets/icons/app.png", - "notification": { - "icon": "./assets/icons/loading.png", - "color": "#000000" - }, - "loading": { - "icon": "https://s3.amazonaws.com/exp-brand-assets/ExponentEmptyManifest_192.png", - "hideExponentText": false - }, - "packagerOpts": { - "assetExts": ["ttf","otf"], - "nonPersistent": "" - }, - "ios": { - "supportsTablet": true - } -} diff --git a/main.js b/main.js deleted file mode 100644 index 5635ff4d8..000000000 --- a/main.js +++ /dev/null @@ -1,8 +0,0 @@ -'use strict'; - -// cljsbuild adds a preamble mentioning goog so hack around it -window.goog = { - provide() {}, - require() {}, -}; -require('./target/env/index.js'); diff --git a/native/cljs/orcpub/core.cljs b/native/cljs/orcpub/core.cljs deleted file mode 100644 index acbcb08a9..000000000 --- a/native/cljs/orcpub/core.cljs +++ /dev/null @@ -1,32 +0,0 @@ -(ns orcpub.core - (:require [reagent.core :as r :refer [atom]] - [re-frame.core :refer [subscribe dispatch dispatch-sync]] - [orcpub.dnd.e5.events] - [orcpub.dnd.e5.equipment-subs] - [orcpub.dnd.e5.subs] - [orcpub.dnd.e5.db] - [orcpub.views :refer [text view image touchable-without-feedback app-registry Alert]] - [orcpub.dnd.e5.native-views :as v5e])) - -(defn alert [title] - (.alert Alert title)) - -(defn hello-button [] - [touchable-without-feedback {:on-press #(alert "HELLO!")} - [view {:style {:border-width 1 :border-color "#f0a100" :padding 10 :border-radius 5}} - [text {:style {:color "white" :text-align "center" :font-weight "bold"}} "press me"]]]) - -(defn app-root [] - (prn "APP ROOT") - [view {:style {:flex 1}} - [view {:style {:background-color "#313a4d" - :padding-top 20 - :padding-bottom 5 - :padding-left 5 - :padding-right 5}} - [image {:source (js/require "./assets/images/dmv-logo.png")}]] - [v5e/character-builder]]) - -(defn init [] - (dispatch-sync [:initialize-db]) - (.registerComponent app-registry "main" #(r/reactify-component app-root))) diff --git a/native/cljs/orcpub/dnd/e5/native-views.cljs b/native/cljs/orcpub/dnd/e5/native-views.cljs deleted file mode 100644 index cb4f7a2fb..000000000 --- a/native/cljs/orcpub/dnd/e5/native-views.cljs +++ /dev/null @@ -1,324 +0,0 @@ -(ns orcpub.dnd.e5.native-views - (:require [orcpub.views :refer [view - scroll-view - text - touchable-without-feedback - main-text-color - light-text-color]] - [orcpub.entity :as entity] - [orcpub.views-aux :as views-aux] - [orcpub.template :as t] - [clojure.string :as s] - [reagent.core :as r] - [clojure.pprint :refer [pprint]] - [re-frame.core :refer [subscribe dispatch dispatch-sync]])) - -(def tab-style - {:padding 10 - :flex 1 - :border-bottom-color main-text-color - :opacity 0.4 - :height 45 - :flex-direction :row - :justify-content :space-around}) - -(def builder-tab-style - (merge - tab-style - {:border-bottom-width 5})) - -(def selected-tab-style - {:opacity 1}) - -(def selected-builder-tab-style - (merge - builder-tab-style - selected-tab-style)) - -(defn builder-tab [title key selected-tab] - [touchable-without-feedback {:on-press #(reset! selected-tab key)} - [view {:style (if (= key @selected-tab) - selected-builder-tab-style - builder-tab-style)} - [text {:style {:font-weight :bold - :color main-text-color - :font-size 18}} - title]]]) - -(def pages - [{:name "Race" - :icon "woman-elf-face" - :tags #{:race :subrace}} - {:name "Background" - :icon "ages" - :tags #{:background}} - {:name "Proficiencies" - :icon "juggler" - :tags #{:profs}}]) - -(def options-tab-style - (merge - tab-style - {:border-bottom-width 2 - :height 40})) - -(def selected-options-tab-style - (merge - options-tab-style - selected-tab-style)) - -(defn options-tab [title i selected-tab] - [touchable-without-feedback {:on-press #(do (prn "I" i) (reset! selected-tab i))} - [view {:style (if (= i @selected-tab) - selected-options-tab-style - options-tab-style)} - [text {:style {:font-weight :bold - :color main-text-color - :font-size 12}} - title]]]) - -(def unselected-option-style - {:padding 12 - :margin 2 - :border-width 2 - :border-radius 5 - :border-color light-text-color}) - -(def selected-option-style - (assoc - unselected-option-style - :border-width 5 - :border-color main-text-color)) - -(defn option-view [option-path - selection - disable-select-new? - homebrew? - option] - (let [{:keys [name - key - selected? - selectable? - multiselect? - option-path - select-fn - help - has-named-mods? - modifiers-str - failed-prereqs] :as data} - (views-aux/option-selector-data option-path - selection - disable-select-new? - homebrew? - option)] - [touchable-without-feedback - {:on-press select-fn} - [view {:style (if selected? - selected-option-style - unselected-option-style)} - [text name]]])) - -(defn selection-section-title [title] - (prn "SELETCION SECTION TITLE" title) - [view {:style {:margin-left 5}} - [text {:style {:font-size 16 - :font-weight :bold}} - title]]) - -(defn selection-section-parent-title [title] - (prn "SELECTION SECTION PARENT TITLE" title) - [view {:style {:margin-left 5 - :margin-bottom 2}} - [text {:style {:font-style :italic - :font-size 14 - :color light-text-color}} - title]]) - -(defn align-items-c [s] - (assoc s :align-items :center)) - -(defn h [s v] - (assoc s :height v)) - -(defn w [s v] - (assoc s :width v)) - -(defn i [s] - (assoc s :font-style :italic)) - -(defn remaining-bubble [value color left-offset top-offset] - [view {:style {:background-color color - :border-color color - :border-radius 12 - :border-width 12}} - [text - {:style {:position :absolute - :left left-offset - :top top-offset - :font-weight :bold - :font-size (or font-size 14) - :color :white}} - value]]) - -(defn remaining-indicator [remaining & [size font-size]] - (remaining-bubble remaining :red -4 -8)) - -(def remaining-text-style - {:margin-left 5 - :font-style :italic}) - -(def remaining-view-style - {:align-items :center - :flex-direction :row}) - -(defn remaining-component [max remaining] - [view {:style {:margin-left 10}} - (cond - (pos? remaining) - [view {:style remaining-view-style} - (remaining-indicator remaining) - [text {:style remaining-text-style} - "remaining"]] - - (or (zero? remaining) - (and (nil? max) - (neg? remaining))) - [view {:style remaining-view-style} - (remaining-bubble "\u2713" :green -6 -9) - [text {:style remaining-text-style} - "complete"]] - - (neg? remaining) - [view {:style remaining-view-style} - [text {:style {:font-style :italic - :margin-right 5}} - "remove"] - (remaining-bubble (Math/abs remaining) :red -4 -8)])]) - -(defn selection-section-base [] - (let [expanded? (r/atom false)] - (fn [{:keys [title path parent-title name icon help max min remaining body hide-lock? hide-homebrew?]}] - (let [locked? @(subscribe [:locked path]) - homebrew? @(subscribe [:homebrew? path])] - [view {:style {:padding 5 - :margin-bottom 20}} - (if (and (or title name) parent-title) - (selection-section-parent-title parent-title)) - [view - #_(if icon (views5e/svg-icon icon 24)) - (if (or title name) - (selection-section-title (or title name)) - (if parent-title - (selection-section-parent-title parent-title))) - #_(if (and path help) - [show-info-button expanded?]) - #_(if (not hide-lock?) - [:i.fa.f-s-16.m-l-10.m-r-5.pointer - {:class-name (if locked? "fa-lock" "fa-unlock-alt opacity-5 hover-opacity-full") - :on-click #(dispatch [:toggle-locked path])}]) - #_(if (not hide-homebrew?) - [:span.pointer - {:class-name (if (not homebrew?) "opacity-5 hover-opacity-full") - :on-click #(dispatch [:toggle-homebrew path])} - (views5e/svg-icon "beer-stein" 18)])] - #_(if (and help path @expanded?) - [help-section help]) - (if (int? min) - [view {:style {:flex-direction :row - :align-items :center - :padding-horizontal 5 - :justify-content :space-between}} - [text {:style {:font-style :italic}} - (str "select " (cond - (= min max) min - (zero? min) (if (nil? max) - "any number" - (str "up to " max)) - :else (str "at least " min)))] - (remaining-component max remaining)]) - body])))) - -(defn selection-view [path {:keys [::t/key ::t/name ::t/options] :as selection} _ _ _] - [view {:style {:margin-bottom 10}} - (doall - (map - (fn [{:keys [::t/key ::t/name] :as option}] - ^{:key key} - [option-view - path - selection - false - false - option]) - options))]) - -(defn selection-section [title built-template option-paths ui-fns selection num-columns remaining & [hide-homebrew?]] - (let [path (entity/actual-path selection) - {:keys [disable-select-new? homebrew?] :as data} - (views-aux/selection-section-data - title - built-template - option-paths - ui-fns - selection-view - selection - num-columns - remaining - hide-homebrew?)] - [selection-section-base data])) - -(defn build-view [] - (let [selected-tab-index (r/atom 0)] - (fn [] - (let [character @(subscribe [:character]) - built-template @(subscribe [:built-template]) - available-selections @(subscribe [:available-selections]) - option-paths @(subscribe [:option-paths]) - built-char @(subscribe [:built-character]) - {:keys [tags ui-fns components] :as page} (pages @selected-tab-index) - selections (entity/tagged-selections available-selections tags) - final-selections (entity/combine-selections selections)] - (pprint character) - [view {:style {:flex 1}} - [view {:style {:flex-direction :row}} - [view {:style {:flex-direction :row - :flex 1 - :padding 10 - :justify-content :space-around}} - (doall - (map-indexed - (fn [i {:keys [name icon tags]}] - ^{:key name} - [options-tab name i selected-tab-index]) - pages))]] - [scroll-view {:style {:padding 10}} - (doall - (map - (fn [{:keys [::t/key ::t/name] :as selection}] - (let [path (entity/actual-path selection)] - ^{:key (s/join "," path)} - [selection-section - name - built-template - option-paths - ui-fns - selection - 1 - (entity/count-remaining built-template character selection) - false])) - final-selections))]])))) - - -(defn character-builder [] - (let [selected-tab (r/atom :options)] - [view {:style {:align-items :center - :flex 1}} - [view {:style {:flex-direction :row - :margin-top 10 - :padding 10}} - [builder-tab "BUILD" :options selected-tab] - [builder-tab "DESCRIBE" :description selected-tab] - [builder-tab "VIEW" :sheet selected-tab]] - [view {:style {:flex 1 - :flex-direction :row}} - [build-view]]])) diff --git a/native/cljs/orcpub/views.cljs b/native/cljs/orcpub/views.cljs deleted file mode 100644 index 7dab2a6b3..000000000 --- a/native/cljs/orcpub/views.cljs +++ /dev/null @@ -1,22 +0,0 @@ -(ns orcpub.views - (:require [reagent.core :as r :refer [atom]])) - -(def ReactNative (js/require "react-native")) -#_(def FontAwesome (js/require "react-native-fontawesome")) -#_(def FontAwesomeIcons (.-Icons FontAwesome)) - -#_(def fa (r/adapt-react-class FontAwesome)) -#_(defn fa-icon [icon-name] - [fa (aget FontAwesomeIcons icon-name)]) - -(def app-registry (.-AppRegistry ReactNative)) -(def text (r/adapt-react-class (.-Text ReactNative))) -(def view (r/adapt-react-class (.-View ReactNative))) -(def scroll-view (r/adapt-react-class (.-ScrollView ReactNative))) -(def image (r/adapt-react-class (.-Image ReactNative))) -(def touchable-highlight (r/adapt-react-class (.-TouchableHighlight ReactNative))) -(def touchable-without-feedback (r/adapt-react-class (.-TouchableWithoutFeedback ReactNative))) -(def Alert (.-Alert ReactNative)) - -(def main-text-color "#727272") -(def light-text-color "#BBBBBB") diff --git a/package.json b/package.json deleted file mode 100644 index 5b30b93c2..000000000 --- a/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "orcpub", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "main": "main.js", - "dependencies": { - "expo": "^17.0.0", - "react": "16.0.0-alpha.6", - "react-native": "https://github.com/expo/react-native/archive/sdk-17.0.0.tar.gz", - "react-native-fontawesome": "^5.7.0" - } -} diff --git a/src/clj/orcpub/db/schema.clj b/src/clj/orcpub/db/schema.clj index 463c369d5..06ed3c833 100644 --- a/src/clj/orcpub/db/schema.clj +++ b/src/clj/orcpub/db/schema.clj @@ -145,6 +145,9 @@ {:db/ident :orcpub.user/password-reset-key :db/valueType :db.type/string :db/cardinality :db.cardinality/one} + {:db/ident :orcpub.user/pending-email + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} {:db/ident :orcpub.user/following :db/valueType :db.type/ref :db/cardinality :db.cardinality/many}]) diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index 8af50a0ea..5da538f8c 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -9,10 +9,10 @@ (defn verification-email-html [first-and-last-name username verification-url] [:div - (str "Dear OrcPub Patron,") + (str "Dear Dungeon Master's Vault Patron,") [:br] [:br] - "Your OrcPub account is almost ready, we just need you to verify your email address going the following URL to confirm that you are authorized to use this email address:" + "Your Dungeon Master's Vault account is almost ready, we just need you to verify your email address going the following URL to confirm that you are authorized to use this email address:" [:br] [:br] [:a {:href verification-url} verification-url] @@ -21,37 +21,74 @@ "Sincerely," [:br] [:br] - "The OrcPub Team"]) + "The Dungeon Master's Vault Team"]) (defn verification-email [first-and-last-name username verification-url] [{:type "text/html" :content (hiccup/html (verification-email-html first-and-last-name username verification-url))}]) +(defn email-change-verification-html + "Email body for existing users changing their email (distinct from registration)." + [username verification-url] + [:div + "Dear Dungeon Master's Vault Patron," + [:br] + [:br] + "You requested to change the email address on your account (" username "). " + "Please visit the following URL to confirm this change:" + [:br] + [:br] + [:a {:href verification-url} verification-url] + [:br] + [:br] + "If you did not request this change, you can safely ignore this email." + [:br] + [:br] + "Sincerely," + [:br] + [:br] + "The Dungeon Master's Vault Team"]) + +(defn email-change-verification-email [username verification-url] + [{:type "text/html" + :content (hiccup/html (email-change-verification-html username verification-url))}]) + (defn email-cfg [] - {:user (environ/env :email-access-key) - :pass (environ/env :email-secret-key) - :host (environ/env :email-server-url) - :port (Integer/parseInt (or (environ/env :email-server-port) "587")) - :ssl (or (str/to-bool (environ/env :email-ssl)) nil) - :tls (or (str/to-bool (environ/env :email-tls)) nil) - }) + (let [cfg {:user (environ/env :email-access-key) + :pass (environ/env :email-secret-key) + :host (environ/env :email-server-url) + :port (Integer/parseInt (or (environ/env :email-server-port) "587")) + :ssl (or (str/to-bool (environ/env :email-ssl)) nil) + :tls (or (str/to-bool (environ/env :email-tls)) nil)}] + cfg)) (defn emailfrom [] - (if (not (s/blank? (environ/env :email-from-address))) (environ/env :email-from-address) (str "no-reply@orcpub.com"))) + (if (not (s/blank? (environ/env :email-from-address))) (environ/env :email-from-address) (str "no-reply@dungeonmastersvault.com"))) (defn send-verification-email [base-url {:keys [email username first-and-last-name]} verification-key] (postal/send-message (email-cfg) - {:from (str "OrcPub Team <" (emailfrom) ">") + {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") :to email - :subject "OrcPub Email Verification" + :subject "Dungeon Master's Vault Email Verification" :body (verification-email first-and-last-name username (str base-url (routes/path-for routes/verify-route) "?key=" verification-key))})) +(defn send-email-change-verification + "Send a verification email for an email-change request (not registration)." + [base-url {:keys [email username]} verification-key] + (postal/send-message (email-cfg) + {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") + :to email + :subject "Dungeon Master's Vault Email Change Verification" + :body (email-change-verification-email + username + (str base-url (routes/path-for routes/verify-route) "?key=" verification-key))})) + (defn reset-password-email-html [first-and-last-name reset-url] [:div - (str "Dear OrcPub Patron") + (str "Dear Dungeon Master's Vault Patron") [:br] [:br] "We received a request to reset your password, to do so please go to the following URL to complete the reset." @@ -66,7 +103,7 @@ "Sincerely," [:br] [:br] - "The OrcPub Team"]) + "The Dungeon Master's Vault Team"]) (defn reset-password-email [first-and-last-name reset-url] [{:type "text/html" @@ -74,9 +111,9 @@ (defn send-reset-email [base-url {:keys [email username first-and-last-name]} reset-key] (postal/send-message (email-cfg) - {:from (str "OrcPub Team <" (emailfrom) ">") + {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") :to email - :subject "OrcPub Password Reset" + :subject "Dungeon Master's Vault Password Reset" :body (reset-password-email first-and-last-name (str base-url (routes/path-for routes/reset-password-page-route) "?key=" reset-key))})) @@ -84,7 +121,7 @@ (defn send-error-email [context exception] (if (not-empty (environ/env :email-errors-to)) (postal/send-message (email-cfg) - {:from (str "OrcPub Errors <" (emailfrom) ">") + {:from (str "Dungeon Master's Vault Errors <" (emailfrom) ">") :to (str (environ/env :email-errors-to)) :subject "Exception" :body [{:type "text/plain" diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 6bf5ae706..3fa7c84ac 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -68,10 +68,13 @@ :in $ ?username :where [?e :orcpub.user/username ?username]]) +;; Case-insensitive email lookup to guard against mixed-case legacy data. +;; Callers must pass a lowercased email. (def email-query '[:find ?e :in $ ?email - :where [?e :orcpub.user/email ?email]]) + :where [?e :orcpub.user/email ?stored] + [(clojure.string/lower-case ?stored) ?email]]) (defn find-user-by-username-or-email [db username-or-email] (d/q @@ -200,9 +203,11 @@ (d/pull-many db '[:orcpub.user/username] ids))) (defn user-body [db user] - {:username (:orcpub.user/username user) - :email (:orcpub.user/email user) - :following (following-usernames db (map :db/id (:orcpub.user/following user)))}) + (cond-> {:username (:orcpub.user/username user) + :email (:orcpub.user/email user) + :following (following-usernames db (map :db/id (:orcpub.user/following user)))} + (:orcpub.user/pending-email user) + (assoc :pending-email (:orcpub.user/pending-email user)))) (defn bad-credentials-response [db username ip] (security/add-failed-login-attempt! username ip) @@ -267,6 +272,12 @@ params verification-key)) +(defn send-email-change-verification [request params verification-key] + (email/send-email-change-verification + (base-url request) + params + verification-key)) + (defn do-verification [request params conn & [tx-data]] (let [verification-key (str (java.util.UUID/randomUUID)) now (java.util.Date.)] @@ -330,16 +341,43 @@ (let [{:keys [:orcpub.user/verification-sent :orcpub.user/verified? :orcpub.user/username + :orcpub.user/pending-email :db/id] :as user} (user-for-verification-key (d/db conn) key)] (if username - (if verified? + (cond + (and verified? (nil? pending-email)) (redirect route-map/verify-success-route) - (if (or (nil? verification-sent) - (verification-expired? verification-sent)) - (redirect route-map/verify-failed-route) - (do (d/transact conn [{:db/id id - :orcpub.user/verified? true}]) - (redirect route-map/verify-success-route)))) + + (or (nil? verification-sent) + (verification-expired? verification-sent)) + ;; Clean up stale pending state so user can request a fresh change + (do (let [retractions (cond-> [[:db/retract id :orcpub.user/verification-key key] + [:db/retract id :orcpub.user/verification-sent verification-sent]] + pending-email + (conj [:db/retract id :orcpub.user/pending-email pending-email]))] + @(d/transact conn retractions)) + (redirect route-map/verify-failed-route)) + + pending-email + ;; Guard: re-check that the target email hasn't been claimed since request. + ;; All paths retract verification-key and verification-sent to prevent + ;; link reuse and avoid stale rate-limit data. + (if (seq (d/q email-query (d/db conn) pending-email)) + (do @(d/transact conn [[:db/retract id :orcpub.user/pending-email pending-email] + [:db/retract id :orcpub.user/verification-key key] + [:db/retract id :orcpub.user/verification-sent verification-sent]]) + (redirect route-map/verify-failed-route)) + (do @(d/transact conn [{:db/id id + :orcpub.user/email pending-email} + [:db/retract id :orcpub.user/pending-email pending-email] + [:db/retract id :orcpub.user/verification-key key] + [:db/retract id :orcpub.user/verification-sent verification-sent]]) + (redirect route-map/verify-success-route))) + + :else + (do @(d/transact conn [{:db/id id + :orcpub.user/verified? true}]) + (redirect route-map/verify-success-route))) {:status 400})) {:status 400})) @@ -352,7 +390,7 @@ (redirect route-map/verify-success-route) (do-verification request (merge query-params - {:first-and-last-name "OrcPub Patron"}) + {:first-and-last-name "DMV Patron"}) conn {:db/id id})))) @@ -365,7 +403,7 @@ :orcpub.user/password-reset-sent (java.util.Date.)}]) (email/send-reset-email (base-url request) - {:first-and-last-name "OrcPub Patron" + {:first-and-last-name "DMV Patron" :email email} key) {:status 200})) @@ -536,7 +574,7 @@ :where [?e :orcpub.user/password-reset-key ?key]]) (def default-title - "The New OrcPub: D&D 5e Character Builder/Generator") + "Dungeon Master's Vault: D&D 5e Character Builder/Generator") (def default-description "Dungeons & Dragons 5th Edition (D&D 5e) character builder/generator and digital character sheet far beyond any other in the multiverse.") @@ -595,7 +633,7 @@ (check-field username-query (:username query-params) db)) (defn check-email [{:keys [db query-params]}] - (check-field email-query (:email query-params) db)) + (check-field email-query (some-> (:email query-params) s/lower-case) db)) (defn character-for-id [db id] (d/pull db '[*] id)) @@ -896,6 +934,106 @@ @(d/transact conn [[:db/retractEntity user]]) {:status 200})) +(defn rate-limit-remaining-secs + "Seconds until the user can act again. In the 0–1 min zone (email in transit) + returns time until the 1-min resend window opens. In the 1–5 min zone (for a + different email) returns time until the 5-min cooldown expires." + [verification-sent new-email pending-email] + (when verification-sent + (let [elapsed-ms (- (System/currentTimeMillis) (.getTime ^java.util.Date verification-sent)) + ;; If same email, they're waiting for the 1-min resend window to open. + ;; If different email, they're waiting for the full 5-min cooldown. + target-ms (if (= new-email pending-email) + (* 1 60 1000) + (* 5 60 1000)) + remaining-ms (- target-ms elapsed-ms)] + (when (pos? remaining-ms) + (int (Math/ceil (/ remaining-ms 1000.0))))))) + +(defn email-change-rate-limited? [verification-sent pending-email new-email] + ;; Only rate-limit if the last key was generated for a pending email change + ;; (not for initial registration verification). + ;; Three zones from verification-sent: + ;; 0–1 min → too soon, email is in transit (always blocked) + ;; 1–5 min → free resend allowed for same email, otherwise blocked + ;; 5+ min → open for any request + (and pending-email + verification-sent + (let [elapsed-ms (- (System/currentTimeMillis) (.getTime ^java.util.Date verification-sent)) + same-email? (= new-email pending-email)] + (cond + (>= elapsed-ms (* 5 60 1000)) false ;; past cooldown + (< elapsed-ms (* 1 60 1000)) true ;; too soon + :else (not same-email?)))) ;; 1-5 min: resend ok, new email blocked + ) + +(defn request-email-change [{:keys [transit-params db conn identity] :as request}] + (try + ;; Client sends {:new-email "..."} (confirm-email is validated client-side only) + (let [new-email (s/lower-case (s/trim (str (:new-email transit-params)))) + username (:user identity)] + (if (nil? username) + {:status 400 :body {:error :user-not-found}} + (let [{:keys [:db/id + :orcpub.user/email + :orcpub.user/pending-email + :orcpub.user/verification-sent] :as user} (find-user-by-username db username)] + (cond + (nil? id) + {:status 400 :body {:error :user-not-found}} + + (registration/bad-email? new-email) + {:status 400 :body {:error :invalid-email}} + + (= new-email (some-> email s/lower-case)) + {:status 400 :body {:error :same-as-current}} + + (email-change-rate-limited? verification-sent pending-email new-email) + {:status 429 :body {:error :too-many-requests + :retry-after-secs (rate-limit-remaining-secs verification-sent new-email pending-email)}} + + ;; Check no other account already owns this email + (seq (d/q email-query db new-email)) + {:status 400 :body {:error :email-taken}} + + ;; Free resend: same email, 1–5 min after original send. Re-send with + ;; existing key and don't update verification-sent (no rolling window). + (and (= new-email pending-email) + verification-sent + (let [elapsed (- (System/currentTimeMillis) (.getTime ^java.util.Date verification-sent))] + (and (>= elapsed (* 1 60 1000)) + (< elapsed (* 5 60 1000))))) + (try + (send-email-change-verification request + {:email new-email :username username} + (:orcpub.user/verification-key user)) + {:status 200 :body {:pending-email new-email}} + (catch Throwable e + (prn "Email resend failed:" (.getMessage e)) + {:status 500 :body {:error :email-send-failed}})) + + :else + (let [verification-key (str (java.util.UUID/randomUUID)) + now (java.util.Date.)] + @(d/transact conn [{:db/id id + :orcpub.user/pending-email new-email + :orcpub.user/verification-key verification-key + :orcpub.user/verification-sent now}]) + ;; Roll back pending-email if verification email fails to send + (try + (send-email-change-verification request + {:email new-email :username username} + verification-key) + {:status 200 :body {:pending-email new-email}} + (catch Throwable e + (prn "Email send failed, rolling back pending state:" (.getMessage e)) + ;; Full rollback: retract all attributes set by the failed attempt + @(d/transact conn [[:db/retract id :orcpub.user/pending-email new-email] + [:db/retract id :orcpub.user/verification-key verification-key] + [:db/retract id :orcpub.user/verification-sent now]]) + {:status 500 :body {:error :email-send-failed}}))))))) + (catch Throwable e (prn e) (throw e)))) + (defn character-summary-description [{:keys [::char5e/race-name ::char5e/subrace-name ::char5e/classes]}] (str race-name " " @@ -1043,6 +1181,8 @@ [(route-map/path-for route-map/user-route) ^:interceptors [check-auth] {:get `get-user :delete `delete-user}] + [(route-map/path-for route-map/user-email-route) ^:interceptors [check-auth] + {:put `request-email-change}] [(route-map/path-for route-map/follow-user-route :user ":user") ^:interceptors [check-auth] {:post `follow-user :delete `unfollow-user}] diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index 71062b06a..4e7eade74 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -103,6 +103,7 @@ (def check-email-route :check-email) (def check-username-route :check-username) (def user-route :user) +(def user-email-route :user-email) (def reset-password-page-route :reset-password-page) (def reset-password-route :reset-password) (def send-password-reset-route :send-password-reset) @@ -124,8 +125,9 @@ "re-verify" re-verify-route "register" register-route "login" login-route - "user" user-route - + "user" {"" user-route + "/email" user-email-route} + "character.pdf" character-pdf-route "check-email" check-email-route "check-username" check-username-route diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index fa147b328..e50bd4292 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -979,6 +979,54 @@ :headers (authorization-headers db) :url (backend-url path)}}))) +(reg-event-fx + :change-email + (fn [{:keys [db]} [_ new-email]] + {:db (dissoc db :email-change-sent? :email-change-error) + :http {:method :put + :headers (authorization-headers db) + :url (backend-url (routes/path-for routes/user-email-route)) + :transit-params {:new-email new-email} + :on-success [:change-email-success] + :on-failure [:change-email-failure]}})) + +(reg-event-db + :change-email-success + (fn [db [_ response]] + (-> db + (assoc :email-change-sent? true) + ;; Use server-canonical (lowercased/trimmed) email for display + (assoc-in [:user-data :user-data :pending-email] + (-> response :body :pending-email))))) + +(reg-event-db + :change-email-failure + (fn [db [_ response]] + (let [body (:body response) + error (:error body)] + (assoc db :email-change-error + (case error + :email-taken "That email address is already in use by another account." + :invalid-email "Please enter a valid email address." + :same-as-current "That is already your current email address." + :too-many-requests + (let [secs (:retry-after-secs body)] + (if (and secs (pos? secs)) + (if (<= secs 60) + ;; 0–1 min zone: email is in transit, show short countdown + (str "Your email is on its way. You can resend in " secs " second" (when (> secs 1) "s") ".") + ;; 1–5 min zone for a different email: show minutes + (let [mins (.ceil js/Math (/ secs 60))] + (str "Please wait " mins " minute" (when (> mins 1) "s") " before requesting another change."))) + "Please wait a few minutes before requesting another email change.")) + :email-send-failed "Verification email could not be sent. Please try again later." + "There was an error updating your email. Please try again."))))) + +(reg-event-db + :change-email-clear + (fn [db _] + (dissoc db :email-change-sent? :email-change-error))) + (reg-event-fx :unfollow-user (fn [{:keys [db]} [_ username]] diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index b66bfd809..f0249d4ca 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -251,6 +251,21 @@ (fn [db _] (-> db :user-data :user-data :email))) +(reg-sub + :pending-email + (fn [db _] + (-> db :user-data :user-data :pending-email))) + +(reg-sub + :email-change-sent? + (fn [db _] + (:email-change-sent? db))) + +(reg-sub + :email-change-error + (fn [db _] + (:email-change-error db))) + (defn built-template [template selected-plugin-options] template #_(let [selected-plugins (map diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 4f2a393bb..b31c4e99a 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -1450,16 +1450,18 @@ [:div.content.f-w-n.f-s-12 [:div.flex.justify-cont-s-b.align-items-c.flex-wrap.p-10 [:div - [:div.m-b-5 "Icons made by Lorc, Caduceus, and Delapouite. Available on " [:a.orange {:href "http://game-icons.net"} "http://game-icons.net"]]] + [:div.m-b-5 "Icons made by Lorc, Caduceus, and Delapouite. Available on " [:a.orange {:href "http://game-icons.net"} "http://game-icons.net"]] + [:div.m-b-5 "Artwork provided by the talented Sandra. Available on " [:a.orange {:href "https://www.deviantart.com/sandara" :target :_blank} "Deviantart"]]] [:div.m-l-10 [:a.orange {:href "https://github.com/Orcpub/orcpub/issues" :target :_blank} "Feedback/Bug Reports"]] [:div.m-l-10.m-r-10.p-10 [:a.orange {:href "/privacy-policy" :target :_blank} "Privacy Policy"] [:a.orange.m-l-5 {:href "/terms-of-use" :target :_blank} "Terms of Use"]] [:div.legal-footer - [:p "© 2025 " [:a.orange {:href "https://github.com/Orcpub/orcpub/" :target :_blank} "Orcpub"]] - [:p "Wizards of the Coast, Dungeons & Dragons, D&D, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries. © 2025 Wizards. All Rights Reserved. OrcPub.com is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC."] - [:p "Version " (v/version) " (" (v/date) ")"]]] + [:p "© " (.getFullYear (js/Date.)) " " [:a.orange {:href "https://github.com/Orcpub/orcpub/" :target :_blank} "Orcpub"]] + [:p "This site is based on " srd-link " - Wizards of the Coast, Dungeons & Dragons, D&D, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries. © " (.getFullYear (js/Date.)) " Wizards. All Rights Reserved."] + [:p "This site is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC."] + [:p "Version " (v/version) " (" (v/date) ") " (v/description) " edition"]]] [debug-data]]]])]))}))) (def row-style @@ -7501,22 +7503,100 @@ [my-content]]]) (defn my-account-page [] - [content-page - "My Account" - [{:title (str "Delete Account") - :icon "trash" - :on-click #(dispatch - [:show-confirmation - {:confirm-button-text "DELETE ACCOUNT" - :question "Are you sure you want to delete your account, characters, and associated data?" - :event [:delete-account]}])}] - [:div.f-s-24.p-10.white - [:div.p-5 - [:span.f-w-b "Username: "] - [:span @(subscribe [:username])]] - [:div.p-5 - [:span.f-w-b "Email: "] - [:span @(subscribe [:email])]]]]) + (r/with-let [editing? (r/atom false) + new-email (r/atom "") + confirm-email (r/atom "")] + (let [current-email @(subscribe [:email]) + pending-email @(subscribe [:pending-email]) + sent? @(subscribe [:email-change-sent?]) + error @(subscribe [:email-change-error]) + ;; Client-side validation: format check + confirm match + bad-format? (and (seq @new-email) + (registration/bad-email? @new-email)) + emails-dont-match? (and (seq @confirm-email) + (not= @new-email @confirm-email)) + can-submit? (and (seq @new-email) + (not bad-format?) + (= @new-email @confirm-email))] + [content-page + "My Account" + [{:title (str "Delete Account") + :icon "trash" + :on-click #(dispatch + [:show-confirmation + {:confirm-button-text "DELETE ACCOUNT" + :question "Are you sure you want to delete your account, characters, and associated data?" + :event [:delete-account]}])}] + [:div.f-s-24.p-10.white + [:div.p-5 + [:span.f-w-b "Username: "] + [:span @(subscribe [:username])]] + [:div.p-5 + [:span.f-w-b "Email: "] + (cond + sent? + [:div + [:span current-email] + [:div.m-t-5.f-s-14 "A verification email has been sent to " [:strong pending-email] ". Click the link in that email to confirm the change."] + [:button.link-button.m-t-5.f-s-14 + {:on-click #(do (reset! editing? true) + (reset! new-email "") + (reset! confirm-email "") + (dispatch [:change-email-clear]))} + "Change again"]] + + @editing? + [:div.m-t-5 + [:input.input + {:type :email + :value @new-email + :placeholder "New email address" + :on-change #(reset! new-email (event-value %))}] + (when bad-format? + [:div.m-t-5.red "Not a valid email format"]) + ;; Confirm field to prevent typo-induced lockout + [:input.input.m-t-5 + {:type :email + :value @confirm-email + :placeholder "Confirm new email address" + :on-change #(reset! confirm-email (event-value %))}] + (when emails-dont-match? + [:div.m-t-5.red "Email addresses don't match"]) + [:div.m-t-5 + [:button.form-button + {:disabled (not can-submit?) + :on-click #(when can-submit? + (dispatch [:change-email @new-email]))} + "Save"] + [:button.link-button.m-l-10 + {:on-click #(do (reset! editing? false) + (reset! new-email "") + (reset! confirm-email "") + (dispatch [:change-email-clear]))} + "Cancel"]] + (when error + [:div.m-t-5.red error])] + + :else + [:div + [:span current-email] + (when pending-email + [:div.m-t-5.f-s-14 + "Pending: " pending-email " — check your email to verify the change. " + ;; Resend uses the same change-email flow; server enforces 3-zone rate limit + ;; (0–1 min blocked, 1–5 min free resend, 5+ min open) + [:button.link-button.f-s-14 + {:on-click #(dispatch [:change-email pending-email])} + "Resend"] + (when error + [:span.m-l-5.red.f-s-14 error])]) + [:button.link-button.m-l-10 + {:on-click #(do (reset! editing? true) + (reset! new-email "") + (reset! confirm-email "") + (dispatch [:change-email-clear]))} + "Change"]])]]]))) + (defn newb-character-builder-page [] [content-page diff --git a/src/cljs/orcpub/ver.cljc b/src/cljs/orcpub/ver.cljc index 500f7db79..8505bdc17 100644 --- a/src/cljs/orcpub/ver.cljc +++ b/src/cljs/orcpub/ver.cljc @@ -1,5 +1,5 @@ (ns orcpub.ver) ; To be updated by build server -(defn version [] "v2.5.0.28") -(defn date [] "09-07-2025") -(defn description [] "Assault of the Last Stand edition") \ No newline at end of file +(defn version [] "2.4.0.28") +(defn date [] "$(date +%m-%d-%Y)") +(defn description [] "Assault of the Last Stand") \ No newline at end of file diff --git a/test/clj/orcpub/email_change_test.clj b/test/clj/orcpub/email_change_test.clj new file mode 100644 index 000000000..37d7ff3d4 --- /dev/null +++ b/test/clj/orcpub/email_change_test.clj @@ -0,0 +1,313 @@ +(ns orcpub.email-change-test + "Tests for the email change flow (PR #644). + Uses datomock for in-memory Datomic and with-redefs to stub email sending." + (:require + [clojure.test :refer [deftest is testing]] + [datomic.api :as d] + [datomock.core :as dm] + [buddy.hashers :as hashers] + [orcpub.routes :as routes] + [orcpub.route-map :as route-map] + [orcpub.db.schema :as schema]) + (:import [java.util UUID])) + +(defmacro with-conn [conn-binding & body] + `(let [uri# (str "datomic:mem:email-change-test-" (UUID/randomUUID)) + ~conn-binding (do + (d/create-database uri#) + (d/connect uri#))] + (try ~@body + (finally (d/delete-database uri#))))) + +(defn seed-users + "Transact schema and seed two verified users for testing." + [conn] + @(d/transact conn schema/all-schemas) + @(d/transact conn + [{:orcpub.user/username "alice" + :orcpub.user/email "alice@test.com" + :orcpub.user/password (hashers/encrypt "pass123") + :orcpub.user/verified? true} + {:orcpub.user/username "bob" + :orcpub.user/email "bob@test.com" + :orcpub.user/password (hashers/encrypt "pass456") + :orcpub.user/verified? true}])) + +(defn make-request + "Build a minimal request map for request-email-change." + [conn new-email username] + {:transit-params {:new-email new-email} + :db (d/db conn) + :conn conn + :identity {:user username} + ;; send-email-change-verification reads scheme + headers for base-url + :scheme :https + :headers {"host" "localhost"}}) + +(defn find-user [db username] + (d/q '[:find (pull ?e [:orcpub.user/email + :orcpub.user/pending-email + :orcpub.user/verification-key + :orcpub.user/verification-sent + :orcpub.user/verified? + :db/id]) . + :in $ ?username + :where [?e :orcpub.user/username ?username]] + db username)) + +(def success-path (route-map/path-for route-map/verify-success-route)) +(def failed-path (route-map/path-for route-map/verify-failed-route)) + +(defn redirect-location + "Extract the Location header from a redirect response." + [resp] + (get-in resp [:headers "Location"])) + +;; ---------- Tests ---------- + +(deftest test-email-change-happy-path + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Request email change stores pending-email and returns 200" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "newalice@test.com" "alice"))] + (is (= 200 (:status resp))) + (let [user (find-user (d/db mocked-conn) "alice")] + (is (= "newalice@test.com" (:orcpub.user/pending-email user))) + (is (some? (:orcpub.user/verification-key user))) + ;; Original email unchanged until verified + (is (= "alice@test.com" (:orcpub.user/email user))) + + (testing "Verify link swaps email and clears pending-email" + (let [key (:orcpub.user/verification-key user) + vresp (routes/verify {:query-params {:key key} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 302 (:status vresp))) + ;; Must redirect to success, not failure + (is (= success-path (redirect-location vresp))) + (let [updated (find-user (d/db mocked-conn) "alice")] + (is (= "newalice@test.com" (:orcpub.user/email updated))) + (is (nil? (:orcpub.user/pending-email updated))) + ;; Verification key invalidated — link can't be reused + (is (nil? (:orcpub.user/verification-key updated))))))))))))) + +(deftest test-email-change-duplicate-rejected + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Changing to an already-taken email returns 400 :email-taken" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "bob@test.com" "alice"))] + (is (= 400 (:status resp))) + (is (= :email-taken (-> resp :body :error))) + ;; DB unchanged + (let [user (find-user (d/db mocked-conn) "alice")] + (is (= "alice@test.com" (:orcpub.user/email user))) + (is (nil? (:orcpub.user/pending-email user)))))))))) + +(deftest test-email-change-same-as-current + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Changing to your own current email returns 400 :same-as-current" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "alice@test.com" "alice"))] + (is (= 400 (:status resp))) + (is (= :same-as-current (-> resp :body :error))) + ;; DB unchanged + (let [user (find-user (d/db mocked-conn) "alice")] + (is (= "alice@test.com" (:orcpub.user/email user))) + (is (nil? (:orcpub.user/pending-email user)))))))))) + +(deftest test-email-change-invalid-format + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Badly formatted email returns 400 :invalid-email" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "not-an-email" "alice"))] + (is (= 400 (:status resp))) + (is (= :invalid-email (-> resp :body :error))) + ;; DB unchanged + (let [user (find-user (d/db mocked-conn) "alice")] + (is (= "alice@test.com" (:orcpub.user/email user))) + (is (nil? (:orcpub.user/pending-email user)))))))))) + +(deftest test-email-change-nil-email + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Nil new-email returns 400 :invalid-email" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn nil "alice"))] + (is (= 400 (:status resp))) + (is (= :invalid-email (-> resp :body :error)))))) + (testing "Empty string new-email returns 400 :invalid-email" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "" "alice"))] + (is (= 400 (:status resp))) + (is (= :invalid-email (-> resp :body :error))))))))) + +(deftest test-email-change-no-auth + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Request without identity returns 400 :user-not-found" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + {:transit-params {:new-email "x@test.com"} + :db (d/db mocked-conn) + :conn mocked-conn + :identity nil + :scheme :https + :headers {"host" "localhost"}})] + (is (= 400 (:status resp))) + (is (= :user-not-found (-> resp :body :error))))))))) + +(deftest test-email-send-failure-rolls-back + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "When email send fails, pending-email is retracted and returns 500" + (with-redefs [routes/send-email-change-verification + (fn [& _] (throw (Exception. "SMTP down")))] + (let [resp (routes/request-email-change + (make-request mocked-conn "newalice@test.com" "alice"))] + (is (= 500 (:status resp))) + (is (= :email-send-failed (-> resp :body :error))) + ;; Full rollback: pending-email, verification-key, and verification-sent all cleared + (let [user (find-user (d/db mocked-conn) "alice")] + (is (nil? (:orcpub.user/pending-email user))) + (is (nil? (:orcpub.user/verification-key user))) + (is (nil? (:orcpub.user/verification-sent user))) + (is (= "alice@test.com" (:orcpub.user/email user)))))))))) + +(deftest test-expired-verification-cleans-pending + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Expired verification link clears stale pending-email" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + ;; Request a change so pending-email is set + (routes/request-email-change + (make-request mocked-conn "newalice@test.com" "alice")) + (let [user (find-user (d/db mocked-conn) "alice") + key (:orcpub.user/verification-key user)] + ;; Force-expire by backdating verification-sent past the 24h window + @(d/transact mocked-conn + [{:db/id (:db/id user) + :orcpub.user/verification-sent + (java.util.Date. (- (System/currentTimeMillis) (* 25 60 60 1000)))}]) + (let [vresp (routes/verify {:query-params {:key key} + :db (d/db mocked-conn) + :conn mocked-conn})] + ;; Must redirect to failed, not success + (is (= 302 (:status vresp))) + (is (= failed-path (redirect-location vresp))) + ;; All pending state cleaned up, original email unchanged + (let [updated (find-user (d/db mocked-conn) "alice")] + (is (nil? (:orcpub.user/pending-email updated))) + (is (nil? (:orcpub.user/verification-key updated))) + (is (= "alice@test.com" (:orcpub.user/email updated))))))))))) + +(deftest test-race-condition-email-claimed-between-request-and-verify + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "If target email is claimed by another user before verify, swap is rejected" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + ;; Alice requests change to unclaimed@test.com + (routes/request-email-change + (make-request mocked-conn "unclaimed@test.com" "alice")) + (let [user (find-user (d/db mocked-conn) "alice") + key (:orcpub.user/verification-key user)] + ;; Meanwhile, someone else claims that email + @(d/transact mocked-conn + [{:orcpub.user/username "charlie" + :orcpub.user/email "unclaimed@test.com" + :orcpub.user/password (hashers/encrypt "pass789") + :orcpub.user/verified? true}]) + ;; Alice clicks verify — should be rejected + (let [vresp (routes/verify {:query-params {:key key} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 302 (:status vresp))) + (is (= failed-path (redirect-location vresp))) + ;; Alice's email unchanged, pending cleaned up + (let [updated (find-user (d/db mocked-conn) "alice")] + (is (= "alice@test.com" (:orcpub.user/email updated))) + (is (nil? (:orcpub.user/pending-email updated))) + (is (nil? (:orcpub.user/verification-key updated))))))))))) + +(deftest test-rate-limiting + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Immediate resend of same email is blocked (email still in transit)" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp1 (routes/request-email-change + (make-request mocked-conn "new1@test.com" "alice"))] + (is (= 200 (:status resp1))) + ;; Immediate resend — within 1-min transit zone, should be blocked + (let [resp2 (routes/request-email-change + (make-request mocked-conn "new1@test.com" "alice"))] + (is (= 429 (:status resp2))) + ;; retry-after-secs counts down to 1-min mark (resend window) + (is (pos? (-> resp2 :body :retry-after-secs))) + (is (<= (-> resp2 :body :retry-after-secs) 60)))))) + + (testing "Different email within 5 minutes is rate-limited" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "new2@test.com" "alice"))] + (is (= 429 (:status resp))) + ;; retry-after-secs counts down to 5-min mark + (is (pos? (-> resp :body :retry-after-secs))) + ;; pending-email unchanged + (let [user (find-user (d/db mocked-conn) "alice")] + (is (= "new1@test.com" (:orcpub.user/pending-email user))))))) + + (testing "Resend of same email after 1 min is allowed (free resend)" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + ;; Backdate verification-sent to 2 minutes ago (past 1-min transit, within 5-min cooldown) + (let [user (find-user (d/db mocked-conn) "alice")] + @(d/transact mocked-conn + [{:db/id (:db/id user) + :orcpub.user/verification-sent + (java.util.Date. (- (System/currentTimeMillis) (* 2 60 1000)))}])) + (let [resp (routes/request-email-change + (make-request mocked-conn "new1@test.com" "alice"))] + (is (= 200 (:status resp))))))))) + +(deftest test-second-change-replaces-pending + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "After rate-limit window, a new request overwrites old pending-email" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + ;; First request + (routes/request-email-change + (make-request mocked-conn "old-pending@test.com" "alice")) + (let [user (find-user (d/db mocked-conn) "alice") + old-key (:orcpub.user/verification-key user)] + ;; Backdate verification-sent past the 5-min rate limit window + @(d/transact mocked-conn + [{:db/id (:db/id user) + :orcpub.user/verification-sent + (java.util.Date. (- (System/currentTimeMillis) (* 6 60 1000)))}]) + ;; Second request should succeed and overwrite pending-email + (let [resp (routes/request-email-change + (make-request mocked-conn "new-pending@test.com" "alice"))] + (is (= 200 (:status resp))) + (let [updated (find-user (d/db mocked-conn) "alice")] + (is (= "new-pending@test.com" (:orcpub.user/pending-email updated))) + ;; Verification key should be replaced so old link is dead + (is (not= old-key (:orcpub.user/verification-key updated)))))))))))