diff --git a/resources/public/css/meetcute.css b/resources/public/css/meetcute.css index e41ae3a..fa5cba5 100644 --- a/resources/public/css/meetcute.css +++ b/resources/public/css/meetcute.css @@ -6,6 +6,7 @@ vertical-align: baseline; background: transparent; } + html { color: #2b2017; // color: #013917; @@ -15,9 +16,11 @@ html { // color: #ccc; font-family: 'Roboto Mono', monospace; } + pre { text-wrap: wrap; } + a { color: rgb(46, 103, 63); font-weight: 700; @@ -28,16 +31,19 @@ a { border-bottom: 2px solid rgb(42, 98, 59, 0.2); transition: border-bottom 0.1s ease-in-out; } + a:hover { // text-decoration: underline; border-bottom: 2px solid rgb(42, 98, 59, 0.3); transition: border-bottom 0.1s ease-in-out; } + button, a, textarea { font-family: 'Roboto Mono', monospace; } + .btn { color: rgb(188, 181, 175, 1); border: 3px solid rgb(188, 181, 175, 0); @@ -50,54 +56,66 @@ textarea { text-decoration: none; font-weight: bold; } + .btn:hover { color: rgb(108, 98, 90); transition: all 0.1s ease-in-out; border: 3px solid rgb(188, 181, 175, 0); } + .btn.primary { border: 3px solid rgb(188, 181, 175, 0.4); } + .btn.primary:hover { border: 3px solid rgb(188, 181, 175, 0.7); } + .btn.primary.green { background: rgb(42, 98, 59, 0.08); border: 3px solid rgb(42, 98, 59, 0.8); color: rgb(42, 98, 59, 0.8); transition: all 0.1s ease-in-out; } + .btn.primary.green:hover { background: rgb(42, 98, 59, 0.1); border: 3px solid rgb(42, 98, 59, 0.9); color: rgb(42, 98, 59, 1); transition: all 0.1s ease-in-out; } + body { margin: 0; } + html, body { height: 100%; } + input { color: #220b01; transition: color 200ms ease-in-out; font-family: 'Roboto Mono', monospace; accent-color: #054921e0; } + input:focus { color: #000000; transition: color 200ms ease-in-out; } + input[type='radio'] { width: 20px; height: 20px; } + input[type='checkbox'] { width: 16px; height: 16px; } + .input-container input { border: none; box-sizing: border-box; @@ -110,6 +128,7 @@ input[type='checkbox'] { input[type='date'] { cursor: pointer; } + input[type='date']::-webkit-calendar-picker-indicator { width: 100%; height: 22px; @@ -124,6 +143,7 @@ input[type='date']::-webkit-calendar-picker-indicator { // bottom: 0; // visibility: hidden; } + .input-date-overlay { display: block; margin-bottom: -38px; @@ -137,27 +157,33 @@ input[type='date']::-webkit-calendar-picker-indicator { position: relative; pointer-events: none; } + /* keep form inputs from zooming on mobile: https://www.warrenchandler.com/2019/04/02/stop-iphones-from-zooming-in-on-form-fields */ body { line-height: 1.3em; font-size: 14px; } + input, select { font-size: 16px; } + ul { padding-inline-start: 20px; } + li { padding-left: 4px; } + .loading-container { position: fixed; top: 40%; left: 50%; transform: translate(-50%, -50%); } + .loading-container .spinner { width: 120px; height: 120px; @@ -166,14 +192,17 @@ li { background-image: url('/orange-favicon.png'); background-size: contain; } + @keyframes spin { 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } + .bio-row .title { width: fit-content; padding: 16px 12px 0 12px; @@ -184,12 +213,14 @@ li { color: #a19890; font-size: 0.8em; } + .bio-row .required-note { color: rgb(255 106 0); font-size: 1.3em; text-transform: lowercase; margin: 0 4px; } + .bio-row textarea { background: white; border: 3px solid rgba(188, 181, 175, 0.3); @@ -203,6 +234,7 @@ li { width: 98%; transition: all 0.2s ease-in-out; } + .required textarea:empty:not(:focus), .required .editable-input.required-but-empty, .required .editable-input[value='']:not([type='date']):not(:focus) { @@ -218,29 +250,35 @@ li { .bio-row-locations .bio-row { width: 100%; } + .bio-row-value { max-width: 78vw; display: flex; } + .bio-row-value input:focus:not([type='date']) { border: 3px solid rgba(188, 181, 175, 0.6) !important; transition: all 0.2s ease-in-out; } + @media screen and (min-width: 600px) { .bio-row-value { min-width: 370px; } } + .required-but-empty, .required-not-empty { padding: 6px 8px 8px 8px; border: 3px solid transparent; } + .required-but-empty { background-color: rgb(255, 248, 243); border: 3px solid rgb(255, 181, 128); border-radius: 8px; } + .editable-input { background: white; border: 3px solid rgba(188, 181, 175, 0.3); @@ -262,14 +300,26 @@ input#display-phone, .iti--separate-dial-code .iti__selected-flag { background: rgba(35, 81, 49, 0.08) !important; } + #display-phone::placeholder { color: rgba(25, 56, 34, 0.4); } + +input#email, +.iti--separate-dial-code .iti__selected-flag { + background: rgba(35, 81, 49, 0.08) !important; +} + +#email::placeholder { + color: rgba(25, 56, 34, 0.4); +} + .iti__country-list { max-width: 330px; overflow-x: hidden; font-size: 0.8em; } + .oranges-wallpaper { background-color: #ffe2c0; background-image: url('../oranges-tile-repeat.jpg'); @@ -287,9 +337,11 @@ input#display-phone, align-items: center; padding-top: 15vh; } + .oranges-wallpaper form { padding-top: 48px; } + .signin-form-background { box-shadow: 0 0 2px 0 rgba(255, 255, 255, 1); border: 4px solid rgb(42, 98, 59, 1); @@ -319,7 +371,7 @@ details span.title { font-size: 1.5em; } -details > summary { +details>summary { // width: 100%; width: fit-content; // border-top: 1px solid #e0e0e0; @@ -327,13 +379,14 @@ details > summary { cursor: pointer; font-weight: bold; } -details > summary:first-of-type { + +details>summary:first-of-type { border-top: none; margin-top: 8px; } -details[open] > summary::marker, -details > summary::marker, +details[open]>summary::marker, +details>summary::marker, details summary::-webkit-details-marker, details summary::marker { font-size: 1.4em; @@ -349,28 +402,35 @@ table { width: 100%; border: 1px solid #ddd; } + th, td { text-align: left; padding: 4px; } + .invisible { opacity: 0; } .location-field:focus-within, .email-options:focus-within, -.email-options:focus /* the radio btns field is different */ +.email-options:focus + +/* the radio btns field is different */ .email-address:focus, .email-address:focus-within { border: 1px solid #ffffff55; } + .you-signed-in-as .friend { width: fit-content; } + .you-signed-in-as { display: inline-flex; } + .field .edit-icon { fill: white; height: 13px; @@ -379,6 +439,7 @@ td { position: absolute; opacity: 0.5; } + .field { font-size: 0.95em; } @@ -394,13 +455,16 @@ td { background: #9dc7d9; border-radius: 100px; } + .mapbox-container .mapboxgl-canvas-container, .mapbox-container .mapboxgl-canvas-container canvas { border-radius: 100px; } + .mapboxgl-canvas { position: relative !important; } + .mapbox-container .mapboxgl-map { height: 100% !important; } @@ -410,30 +474,36 @@ td { 0% { background-color: #1d7a42; } + 50% { background-color: #013917; } + 100% { background-color: #1d7a42; } } + @keyframes pulsingSize { 0% { transform: scale(1); // font-size: 1em; // transform: translateX(0) translateY(0); } + 50% { transform: scale(1.2); // font-size: 1.2em; // transform: translateX(-10px) translateY(-10px); } + 100% { transform: scale(1); // font-size: 1em; // transform: translateX(0) translateY(0); } } + .mapbox-container .center-point, .mapbox-container .center-point { width: 20px; @@ -451,6 +521,7 @@ td { animation-duration: 3s; animation-iteration-count: infinite; } + .location-fields { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); @@ -458,6 +529,7 @@ td { margin: 12px 0; width: 100%; } + .location-field, .add-location-btn { background: #f6f6f6; @@ -470,7 +542,8 @@ td { padding: 8px; border-radius: 8px; flex: 1; - min-width: 200px; /* Adjust minimum width as needed */ + min-width: 200px; + /* Adjust minimum width as needed */ text-align: center; border: 1px solid #000; padding: 10px; @@ -491,9 +564,11 @@ td { .location-field .location-field-top { margin: -12px 0 0 0; } + .location-field .location-field-bottom { margin: 16px 12px 0 12px; } + .location-field .location-input { background: white; margin: 4px 0 8px 0; @@ -503,25 +578,30 @@ td { transition: all 0.2s ease-in-out; border-radius: 8px; } + .location-field .location-input[value='']:not(:focus) { background-color: rgb(255, 248, 243); border: 3px solid rgb(255, 181, 128) !important; transition: all 0.2s ease-in-out; } + .location-field .location-input:focus { border: 3px solid rgba(188, 181, 175, 0.5) !important; transition: all 0.2s ease-in-out; border-radius: 8px; } + .location-field .location-type-radio { width: 100%; text-align: left; } + .location-field .location-type-radio input { margin: 6px; cursor: pointer; // display: none; } + .location-field .location-type-radio label { margin: 3px; padding: 4px 8px; @@ -531,11 +611,13 @@ td { color: #013917c0; transition: color 0.1s ease-in-out; } + .location-field .location-type-radio label:hover, -.location-field .location-type-radio input:hover + label { +.location-field .location-type-radio input:hover+label { color: #013917e0; transition: color 0.1s ease-in-out; } + .add-location-btn { padding: 24px 10px; min-height: 264px; @@ -550,11 +632,13 @@ td { align-items: center; justify-content: center; } + .add-location-btn:hover { color: #013917; border: 3px dashed rgba(188, 181, 175, 0.8) !important; transition: all 0.2s ease-in-out; } + .ready-for-review { position: fixed; background: white; @@ -572,6 +656,7 @@ td { flex-wrap: wrap-reverse; gap: 12px; } + .ready-for-review button { color: white; background: #013917e0; @@ -583,15 +668,18 @@ td { width: fit-content; min-width: 200px; } + .ready-for-review button:hover { background: #013917; transition: all 0.2s ease-in-out; } + .ready-for-review button.disabled { background: rgba(160, 160, 160, 0.3); color: rgba(160, 160, 160, 0.9); cursor: not-allowed; } + .ready-for-review .errors-list { // width: 336px; text-wrap: balance; @@ -599,14 +687,17 @@ td { font-size: 0.9em; text-align: center; } + @media (min-width: 570px) { .ready-for-review .errors-list { text-align: left; } } + .ready-for-review .errors-list ul { margin-top: 6px; } + .welcome-message { line-height: 1.8em; // text-wrap: balance; @@ -691,6 +782,7 @@ td { margin-left: -20px; box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.3); } + .bio-row .picture svg.cancel-icon:hover { fill: #3b5f46; transition: all 100ms ease-in-out; @@ -711,4 +803,4 @@ td { // .img-upload input[type='submit']:hover { // background-color: #45a049; -// } +// } \ No newline at end of file diff --git a/src/meetcute/auth.clj b/src/meetcute/auth.clj index accc697..424debb 100644 --- a/src/meetcute/auth.clj +++ b/src/meetcute/auth.clj @@ -13,7 +13,10 @@ [meetcute.logic :as logic] [clojure.java.io :as io] [cljs.pprint :as pp] - [smallworld.airtable :as airtable])) + [smallworld.airtable :as airtable]) + (:import [java.util Date])) + +(def email-auth? true) ;; Adding authentication to some of the pages ;; She wants everything to be stateless if possible @@ -21,8 +24,10 @@ (defn- jwt-secret [] (env/get-env-var "JWT_SECRET_KEY")) -(defn create-auth-token [phone] - (jwt/sign {:auth/phone phone} (jwt-secret))) +(defn create-auth-token [{:keys [phone email]}] + (jwt/sign {:auth/phone (some-> phone mc.util/clean-phone) + :auth/email (some-> email mc.util/clean-email)} + (jwt-secret))) (defn verify-auth-token [auth-token] (try @@ -31,10 +36,10 @@ nil))) (defn req->auth-token [req] - (or - (some-> (get-in req [:headers "authorization"]) - (str/split #"\s+") - (second))) + #_(or + (some-> (get-in req [:headers "authorization"]) + (str/split #"\s+") + (second))) (get-in req [:session :auth/jwt])) (defn req->parsed-jwt [req] @@ -72,55 +77,71 @@ (unauthorized-response request)))) ;; ====================================================================== -;; (deprecated for Twilio verify) SMS verification flow +;; Codes flow (comment - {"phone" {:code "123456" - :attempts [{:time 123 :code "1234" :result :success}]}}) + {"email" {:id #uuid "5d14438d-47e3-4a95-845b-5141bdc98ec8" + :code "123456" + :started_at #inst "2024-01-01" + :success? false + :attempts [{:time #inst "2024-01-01" :code "1234" :result :success}]}}) -(defonce sms-sessions (atom {})) +(defonce auth-sessions-state (atom {})) (defn reset-sms-sessions! [] - (reset! sms-sessions {})) + (reset! auth-sessions-state {})) (defn random-code [] (str (+ 100000 (rand-int 900000)))) -(defn add-new-code [sms-sessions phone code] - {:pre [(string? phone) (string? code)]} - (if (get sms-sessions phone) - (update sms-sessions phone assoc :code code) - (assoc sms-sessions phone {:code code - :attempts []}))) - -(defn now [] (java.util.Date.)) +(defn now [] + (Date.)) + +(defn add-new-code [auth-sessions email code] + {:pre [(string? email) (string? code)]} + (if (get auth-sessions email) + (-> auth-sessions + (assoc-in [email :code] code) + (assoc-in [email :success?] nil)) + (assoc auth-sessions email {:id (random-uuid) + :code code + :started_at (now) + :success? nil + :attempts []}))) (def MAX_ATTEMPTS_PER_HOUR 6) (defn in-last-hour? [now time] - (< (- (.getTime now) (.getTime time)) (* 60 60 1000))) - -(defn new-attempt [sms-sessions phone attempted-code] - (let [{:keys [attempts code]} (get sms-sessions phone) - last-hour-attempts (->> attempts - (map :time) - (filter (partial in-last-hour? (now))) - count) - r (cond - (nil? attempted-code) :error - (< MAX_ATTEMPTS_PER_HOUR last-hour-attempts) :error - (= code attempted-code) :success - :else :error) - attempt {:code attempted-code - :time (now) - :result r}] - (update-in sms-sessions [phone :attempts] conj attempt))) - -(defn last-attempt [attempts] + (and time + (< (- (.getTime now) (.getTime time)) + (* 60 60 1000)))) + +(defn count-recent-attempts [attempts now] (->> attempts - (sort-by (fn [{:keys [time]}] - (- (.getTime time)))) - first)) + (map :time) + (filter (partial in-last-hour? now)) + count)) + +(defn new-attempt [auth-sessions email attempted-code] + (if-let [session (get auth-sessions email)] + (let [{:keys [attempts code]} session + current-time (now) + last-hour-attempts (count-recent-attempts attempts current-time) + r (cond + (nil? attempted-code) :error + (>= last-hour-attempts MAX_ATTEMPTS_PER_HOUR) :error + (= code attempted-code) :success + :else :error) + attempt {:code attempted-code + :time current-time + :result r} + success? (= :success r)] + (-> auth-sessions + (update-in [email :attempts] conj attempt) + (assoc-in [email :success?] success?))) + auth-sessions)) + +(defn last-attempt [attempts] (last attempts)) ;; ================================================================================ ;; HTML @@ -174,7 +195,65 @@ (airtable-iframe "https://airtable.com/embed/appF2K8ThWvtrC6Hs/shrZJIaP3ZbuXmiW1") (embed-js-script (io/resource "public/signup.js"))]]) -(defn signup-screen [{:keys [phone phone-input-error code-error started?]}] +(defn email-signup-screen [{:keys [email email-input-error code-error started?]}] + [:div.oranges-wallpaper + [:form {:method "post" :action (if started? + "/meetcute/verify-signup" + "/meetcute/signup")} + [:div.signin-form-background + ;; [:h1 {:style {:font-size "36px" :line-height "1.4em" :margin-bottom "60px" :margin-top "12px"}} "Welcome to" [:br] "MeetCute!"] + [:h2 {:style {:font-size "24px" :line-height "1.4em" :margin "24px"}} + "Sign up"] + (when (or email-input-error code-error) + [:div {:style {:color "red" :min-height "1.4em" :margin-bottom "8px" :text-wrap "balance"}} + (or email-input-error code-error)]) + [:input {:id "email" + :type "email" + :name "email" + :value email + :placeholder "Email address" + :style {:background "rgb(42, 98, 59, 0.1)" + :border-radius "8px" + :width "13em" + :padding "6px 8px" + :margin-right "4px" + :padding-left "8px"}}] + (if-not started? + + [:br] #_[:p {:style {:margin-top "6px" + :color "rgba(25, 56, 34, 0.5);" + :font-size ".8em"}} + "We will text you a code via SMS"] + + [:div + [:label {:for "code"} + [:p {:style {:font-weight "bold" + :margin "24px 4px 4px 4px" + :text-transform "uppercase" + :font-style "italic" + :color "#bcb5af" + :font-size ".8em"}} "Email code:"]] + [:input {:type "text" + :autocomplete "one-time-code" + :name "code" + :style {:background "#66666620" + :border-radius "8px" + :padding "6px 8px" + :margin-right "4px"}}]]) + [:div {:style {:margin-bottom "12px"}}] + [:button.btn.primary.green {:class "btn primary" + :type "submit"} + "Send code"] + [:p {:style {:font-size ".8em" + :margin-top "24px"}} + "Already have an account? " [:a {:href "/meetcute/signin"} "Sign in"]] + (when started? + [:div {:class "resend" :style {:margin-top "2rem"}} + [:p "Didn't get the code? " [:a {:href "/meetcute/signin"} "Try again"]]]) + (embed-js-script (io/resource "public/signin.js"))]]]) + + +(defn sms-signup-screen [{:keys [phone phone-input-error code-error started?]}] [:div.oranges-wallpaper [:form {:method "post" :action (if started? "/meetcute/verify-signup" @@ -241,15 +320,87 @@ (embed-js-script (io/resource "public/signin.js"))]]]) (defn signup-route [_] - (html-response - (signup-screen {:phone "" - :started? false - :phone-input-error nil}))) + (if email-auth? + (html-response + (email-signup-screen {:email "" + :started? false + :email-input-error nil})) + (html-response + (sms-signup-screen {:phone "" + :started? false + :phone-input-error nil})))) ;; ====================================================================== ;; Sign In -(defn signin-screen [{:keys [phone phone-input-error code-error started?]}] +(defn email-signin-screen + + [{:keys [email email-input-error code-error started?] :as opts}] + + [:div.oranges-wallpaper + [:form {:method "post" :action (if started? + "/meetcute/verify" + "/meetcute/signin")} + [:div.signin-form-background + ;; [:h1 {:style {:font-size "36px" :line-height "1.4em" :margin-bottom "60px" :margin-top "12px"}} "Welcome to" [:br] "MeetCute!"] + [:h2 {:style {:font-size "24px" :line-height "1.4em" :margin-top "12px"}} "Sign in"] + [:p {:style {:margin "28px 0 12px 0" :font-size ".88em"}} "Hello from " [:a {:href "https://twitter.com/devonzuegel"} "Devon"] " & " [:a {:href "https://twitter.com/eriktorenberg"} "Erik"] "!"] + [:p {:style {:margin "0 0 32px 0" :font-size ".88em"}} "This is our a little experiment to introduce single friends to each other, & we're excited you're part of it"] + (when (or email-input-error code-error) + [:div {:style {:color "red" :min-height "1.4em" :margin-bottom "8px"}} + (or email-input-error code-error)]) + #_[:label {:for "phone"} + [:p {:style {:font-weight "bold" + :margin "24px 4px 4px 4px" + :text-transform "uppercase" + :font-style "italic" + :color "#bcb5af" + :font-size ".8em"}} "Your phone number:"]] + [:input {:id "email" + :type "email" + :name "email" + :value email + :placeholder "Email address" + :style {:border-radius "8px" + :width "13em" + :padding "6px 8px" + :margin-right "4px" + :padding-left "8x"}}] + (if-not started? + [:br] #_[:p {:style {:margin-top "6px" + :color "rgba(25, 56, 34, 0.5);" + :font-size ".8em"}} + "We will text you a code via SMS"] + [:div + [:label {:for "code"} + [:p {:style {:font-weight "bold" + :margin "24px 4px 4px 4px" + :text-transform "uppercase" + :font-style "italic" + :color "#bcb5af" + :font-size ".8em"}} "Code:"]] + [:input {:type "text" + :autocomplete "one-time-code" + :name "code" + :style {:background "#66666620" + :border-radius "8px" + :padding "6px 8px" + :margin-right "4px"}}]]) + [:div {:style {:margin-bottom "12px"}}] + [:button.btn.primary.green {:type "submit"} + "Send code"] + [:p {:style {:font-size ".8em" + :margin-top "24px"}} + "No account yet? " [:a {:href "/meetcute/signup"} "Sign up →"]] + + (when started? + [:div {:class "resend" :style {:margin-top "2rem"}} + [:p "Didn't get the code? " [:a {:href "/meetcute/signin"} "Try again"]] ; TODO: have this resend the code, instead of starting over entirely + ]) + (embed-js-script (io/resource "public/signin.js"))]]]) + + +(defn sms-signin-screen [{:keys [phone phone-input-error code-error started?]}] [:div.oranges-wallpaper [:form {:method "post" :action (if started? "/meetcute/verify" @@ -327,18 +478,25 @@ :body (base-index (str (hiccup/html hiccup-body)))}) (defn signin-route [_] - (html-response - (signin-screen {:phone "" - :started? false - :phone-input-error nil}))) + (if email-auth? + (html-response + (email-signin-screen {:email "" + :started? false + :email-input-error nil})) + (html-response + (sms-signin-screen {:phone "" + :started? false + :phone-input-error nil})))) (comment (def TEST_SMS_CODE "123456")) + +(def TEST_EMAIL (mc.util/clean-email "test@test.com")) (def TEST_PHONE_NUMBER (mc.util/clean-phone "111-111-1111")) (def TEST_VERIFICATION_ID "VE478b3f02238dee0544e9062cfc16c1ff") -(defn start-signin-route [req] +(defn sms-start-signin-route [req] (let [params (:params req) phone (some-> (:phone params) mc.util/clean-phone)] @@ -347,13 +505,13 @@ (if-not (mc.util/valid-phone? phone) (html-response - (signin-screen {:phone (or (:phone params) "") - :phone-input-error "Invalid phone number"})) + (sms-signin-screen {:phone (or (:phone params) "") + :phone-input-error "Invalid phone number"})) (if-not (logic/existing-phone-number? phone) (html-response - (signin-screen {:phone (or (:phone params) "") - :phone-input-error "Hmmm we couldn't find an account with that phone number"})) + (sms-signin-screen {:phone (or (:phone params) "") + :phone-input-error "Hmmm we couldn't find an account with that phone number"})) (let [verification-id (if (= TEST_PHONE_NUMBER phone) @@ -364,13 +522,66 @@ :error)))] (if (= :error verification-id) (html-response - (signin-screen {:phone (or (:phone params) "") - :phone-input-error "Error sending SMS. Try again later."})) + (sms-signin-screen {:phone (or (:phone params) "") + :phone-input-error "Error sending SMS. Try again later."})) (html-response - (signin-screen {:phone (or (:phone params) "") - :started? true})))))))) + (sms-signin-screen {:phone (or (:phone params) "") + :started? true})))))))) + +(defn start-verification! [{:keys [email]}] + (let [code (random-code) + auth-sessions (swap! auth-sessions-state (fn [auth-sessions] + (add-new-code auth-sessions email code)))] + (email/send-email {:to email + :from-name "MeetCute" + :subject "Sign in to MeetCute" + :body (format "%s is your email code" code)}) + (str (get-in auth-sessions [email :id])))) + +(defn check-code! [{:keys [email code]}] + (let [auth-sessions (swap! auth-sessions-state (fn [auth-sessions] + (new-attempt auth-sessions email code)))] + (get-in auth-sessions [email :success?]))) + +(defn email-start-signin-route [req] + (def -req req) + (let [params (:params req) + email (some-> (:email params) mc.util/clean-email)] -(defn start-signup-route [req] + (println) + (println "Attempting login with email address: " email) + + (if-not (mc.util/valid-email? email) + (html-response + (email-signin-screen {:email (or (:email params) "") + :email-input-error "Invalid email address"})) + + (if-not (logic/existing-email? email) + (html-response + (email-signin-screen {:email (or (:email params) "") + :email-input-error "Hmmm we couldn't find an account with that email"})) + + (let [verification-id + (if (= TEST_EMAIL email) + TEST_VERIFICATION_ID + (try + (start-verification! {:email email}) + (catch Exception _e + :error)))] + (if (= :error verification-id) + (html-response + (email-signin-screen {:email (or (:email params) "") + :email-input-error "Error sending email code. Try again later."})) + (html-response + (email-signin-screen {:email (or (:email params) "") + :started? true})))))))) + +(defn start-signin-route [req] + (if email-auth? + (email-start-signin-route req) + (sms-start-signin-route req))) + +(defn sms-start-signup-route [req] (let [params (:params req) phone (some-> (:phone params) mc.util/clean-phone)] @@ -379,13 +590,13 @@ (if-not (mc.util/valid-phone? phone) (html-response - (signup-screen {:phone (or (:phone params) "") - :phone-input-error "Invalid phone number"})) + (sms-signup-screen {:phone (or (:phone params) "") + :phone-input-error "Invalid phone number"})) (if (logic/existing-phone-number? phone) (html-response - (signup-screen {:phone (or (:phone params) "") - :phone-input-error "Looks like you already have an account!"})) + (sms-signup-screen {:phone (or (:phone params) "") + :phone-input-error "Looks like you already have an account!"})) (let [verification-id (if (= TEST_PHONE_NUMBER phone) @@ -396,28 +607,72 @@ :error)))] (if (= :error verification-id) (html-response - (signup-screen {:phone (or (:phone params) "") - :phone-input-error "Error sending SMS. Try again later."})) + (sms-signup-screen {:phone (or (:phone params) "") + :phone-input-error "Error sending SMS. Try again later."})) (html-response - (signup-screen {:phone (or (:phone params) "") - :started? true})))))))) + (sms-signup-screen {:phone (or (:phone params) "") + :started? true})))))))) -(defn verify-route [req] +(defn email-start-signup-route [req] + (let [params (:params req) + email (some-> (:email params) mc.util/clean-email)] + + (println) + (println "Attempting signup with email: " email) + + (if-not (mc.util/valid-email? email) + (html-response + (email-signup-screen {:email (or (:phone params) "") + :email-input-error "Invalid email"})) + + (if (logic/existing-email? email) + (html-response + (email-signup-screen {:email (or (:email params) "") + :email-input-error "Looks like you already have an account!"})) + + (let [verification-id + (if (= TEST_EMAIL email) + TEST_VERIFICATION_ID + (try + (start-verification! {:email email}) + (catch Exception _e + :error)))] + (if (= :error verification-id) + (html-response + (email-signup-screen {:email (or (:email params) "") + :email-input-error "Error sending email. Try again later."})) + (html-response + (email-signup-screen {:email (or (:email params) "") + :started? true})))))))) + +(defn start-signup-route [req] + (if email-auth? + (email-start-signup-route req) + (sms-start-signup-route req))) + +(defn sms-verify-route [req] (println "made it to verify-route!!!!!!!!!!!!!!!!!!!!!!!!!!!!") (println "params: " (:params req)) (let [params (:params req) error-response (fn [msg] (html-response - (signin-screen {:phone (:phone params) - :started? true - :code-error msg})))] + (sms-signin-screen {:phone (:phone params) + :started? true + :code-error msg})))] (if-let [phone (some-> (:phone params) mc.util/clean-phone)] (if-let [code (some-> (:code params) str/trim)] (let [verify-r (when-not (= TEST_PHONE_NUMBER phone) (try - (when-not (re-matches #"^\d{4}$" code) {:error "Hmm that doesn't match the format of the code"}) - (when-not (sms/check-code! {:phone phone :code code}) {:error "Hmm that's the wrong code..."}) - (catch Exception _e {:error "Hmm that didn't work! Please try again"})))] + (cond + (not (mc.util/valid-code? code)) + {:error "Hmm that doesn't match the format of the code"} + + (sms/check-code! {:phone phone :code code}) + {:success? true} + + :else {:error "Hmm that's the wrong code..."}) + (catch Exception _e + {:error "Hmm that didn't work! Please try again"})))] (if-let [error-msg (:error verify-r)] (error-response error-msg) ;; redirect to the home page with the cookie set @@ -427,20 +682,61 @@ (error-response "Missing code")) (error-response "Missing phone")))) -(defn verify-signup-route [req] +(defn email-verify-route [req] + (println "made it to verify-route!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + (println "params: " (:params req)) + (let [params (:params req) + error-response (fn [msg] + (html-response + (email-signin-screen {:email (:email params) + :started? true + :code-error msg})))] + (if-let [email (some-> (:email params) mc.util/clean-email)] + (if-let [code (some-> (:code params) str/trim)] + (let [verify-r (try + (cond + (= TEST_EMAIL email) {:success? true} + + (not (mc.util/valid-code? code)) + {:error "Hmm that doesn't match the format of the code"} + + (check-code! {:email email :code code}) + {:success? true} + + :else {:error "Hmm that's the wrong code..."}) + (catch Exception _e + {:error "Hmm that didn't work! Please try again"}))] + (if-let [error-msg (:error verify-r)] + (error-response error-msg) + ;; redirect to the home page with the cookie set + ;; this session is now authenticated + (-> (resp/redirect "/meetcute") + (assoc :session {:auth/jwt (create-auth-token {:email email})})))) + (error-response "Missing code")) + (error-response "Missing email")))) + +(defn verify-route [req] + (if email-auth? + (email-verify-route req) + (sms-verify-route req))) + +(defn sms-verify-signup-route [req] (let [params (:params req) error-response (fn [msg] (html-response - (signup-screen {:phone (:phone params) - :started? true - :code-error msg})))] + (sms-signup-screen {:phone (:phone params) + :started? true + :code-error msg})))] (if-let [phone (some-> (:phone params) mc.util/clean-phone)] (if-let [code (some-> (:code params) str/trim)] (let [verify-r (when-not (= TEST_PHONE_NUMBER phone) (try - (when-not (re-matches #"^\d{4}$" code) {:error "Hmm that doesn't match the format of the code"}) - (when-not (sms/check-code! {:phone phone :code code}) {:error "Hmm that's the wrong code..."}) - (catch Exception _e {:error "Hmm that didn't work! Please try again"})))] + (when-not (mc.util/valid-code? code) + {:error "Hmm that doesn't match the format of the code"}) + (when-not (sms/check-code! {:phone phone :code code}) + {:error "Hmm that's the wrong code..."}) + (catch Exception _e + {:error "Hmm that didn't work! Please try again"})))] (if-let [error-msg (:error verify-r)] (error-response error-msg) ;; create the new user, then @@ -464,6 +760,57 @@ (error-response "Missing code")) (error-response "Missing phone")))) +(defn email-verify-signup-route [req] + (def -req req) + (let [params (:params req) + error-response (fn [msg] + (html-response + (email-signup-screen {:email (:email params) + :started? true + :code-error msg})))] + (if-let [email (some-> (:email params) mc.util/clean-email)] + (if-let [code (some-> (:code params) str/trim)] + (let [verify-r (try + (cond + (= TEST_EMAIL email) {:success? true} + + (not (mc.util/valid-code? code)) + {:error "Hmm that doesn't match the format of the code"} + + (check-code! {:email email :code code}) + {:success? true} + + :else {:error "Hmm that's the wrong code..."}) + (catch Exception _e + {:error "Hmm that didn't work! Please try again"}))] + (if-let [error-msg (:error verify-r)] + (error-response error-msg) + ;; create the new user, then + ;; redirect to the home page with the cookie set + ;; this session is now authenticated + (do + (println "🐣 Creating new user in airtable with email: " email) + ; send email to admin notifying them that a new user has signed up: + (email/send-email {:to "hello@smallworld.kiwi" + :from-name "MeetCute logs" + :subject (str "🐣 New user signed up: " email) + :body (str "
" + "View list of users who have not yet been reviewed: " + "https://airtable.com/appF2K8ThWvtrC6Hs/tbl0MIb6C4uOFmNAb/viwNrg3C6HulVYNMh." + "
")}) + (airtable/create-in-base logic/airtable-base + [@logic/airtable-cuties-db-name] + {:fields {:Email email}}) + (-> (resp/redirect "/meetcute/settings") + (assoc :session {:auth/jwt (create-auth-token {:email email})}))))) + (error-response "Missing code")) + (error-response "Missing email")))) + +(defn verify-signup-route [req] + (if email-auth? + (email-verify-signup-route req) + (sms-verify-signup-route req))) + (defn logout-route [_req] (-> (resp/redirect "/meetcute/signin") (assoc :session {:auth/jwt nil}))) \ No newline at end of file diff --git a/src/meetcute/auth_test.clj b/src/meetcute/auth_test.clj new file mode 100644 index 0000000..bebc8ba --- /dev/null +++ b/src/meetcute/auth_test.clj @@ -0,0 +1,101 @@ +(ns meetcute.auth-test + (:require [clojure.test :refer :all] + [meetcute.auth :as auth]) + (:import (java.util Date))) + +(deftest auth-sessions + (testing "successful authentication with correct code" + (let [email "test@test.com" + code (auth/random-code) + initial-auth-sessions (auth/add-new-code {} email code) + approved-auth-sessions (auth/new-attempt initial-auth-sessions email code)] + (is (nil? (get-in initial-auth-sessions [email :success?]))) + (is (true? (get-in approved-auth-sessions [email :success?]))))) + + (testing "failed authentication with incorrect code" + (let [email "test@test.com" + correct-code (auth/random-code) + wrong-code "000000" + initial-auth-sessions (auth/add-new-code {} email correct-code) + failed-auth-sessions (auth/new-attempt initial-auth-sessions email wrong-code)] + (is (= :error (get-in failed-auth-sessions [email :attempts 0 :result]))) + (is (false? (get-in failed-auth-sessions [email :success?]))))) + + (testing "rate limiting - exceeding maximum attempts per hour" + (let [email "test@test.com" + code (auth/random-code) + initial-auth-sessions (auth/add-new-code {} email code) + ; Create 6 failed attempts + attempts-sessions (reduce + (fn [sessions _] + (auth/new-attempt sessions email "wrong-code")) + initial-auth-sessions + (range 6)) + ; Try one more time with correct code + final-attempt (auth/new-attempt attempts-sessions email code)] + (is (= 6 (count (get-in attempts-sessions [email :attempts])))) + (is (= :error (get-in final-attempt [email :attempts 6 :result]))) + (is (false? (get-in final-attempt [email :success?]))))) + + (testing "nil code attempt" + (let [email "test@test.com" + code (auth/random-code) + initial-auth-sessions (auth/add-new-code {} email code) + failed-auth-sessions (auth/new-attempt initial-auth-sessions email nil)] + (is (= :error (get-in failed-auth-sessions [email :attempts 0 :result]))) + (is (false? (get-in failed-auth-sessions [email :success?]))))) + + (testing "multiple sessions for different emails" + (let [email1 "test1@test.com" + email2 "test2@test.com" + code1 (auth/random-code) + code2 (auth/random-code) + sessions (-> {} + (auth/add-new-code email1 code1) + (auth/add-new-code email2 code2) + (auth/new-attempt email1 code1))] + (is (true? (get-in sessions [email1 :success?]))) + (is (nil? (get-in sessions [email2 :success?]))))) + + (testing "updating existing session with new code" + (let [email "test@test.com" + code1 (auth/random-code) + code2 (auth/random-code) + initial-sessions (auth/add-new-code {} email code1) + updated-sessions (auth/add-new-code initial-sessions email code2)] + (is (= code2 (get-in updated-sessions [email :code]))) + (is (= (get-in initial-sessions [email :started_at]) + (get-in updated-sessions [email :started_at]))))) + + (testing "attempts tracking and last attempt retrieval" + (let [email "test@test.com" + code (auth/random-code) + sessions (-> {} + (auth/add-new-code email code) + (auth/new-attempt email "wrong1") + (auth/new-attempt email "wrong2") + (auth/new-attempt email code)) + attempts (get-in sessions [email :attempts]) + last-try (auth/last-attempt attempts)] + (is (= 3 (count attempts))) + (is (= code (:code last-try))) + (is (= :success (:result last-try))))) + + (testing "time-based attempt filtering" + (let [email "test@test.com" + code (auth/random-code) + old-date #inst "2023-01-01" + recent-date (auth/now) + attempts [{:time old-date :code "wrong" :result :error} + {:time recent-date :code "wrong" :result :error}]] + (is (= 1 (->> attempts + (map :time) + (filter (partial auth/in-last-hour? recent-date)) + count))))) + + (testing "reset functionality" + (let [email "test@test.com" + code (auth/random-code)] + (swap! auth/auth-sessions-state auth/add-new-code email code) + (auth/reset-sms-sessions!) + (is (empty? @auth/auth-sessions-state))))) \ No newline at end of file diff --git a/src/meetcute/util.cljc b/src/meetcute/util.cljc index 60093c1..2c3d7b8 100644 --- a/src/meetcute/util.cljc +++ b/src/meetcute/util.cljc @@ -6,6 +6,18 @@ (println "remove-nth: " n) (concat (take n lst) (drop (inc n) lst))) +(defn clean-email + [email] + (str/lower-case (str/trim email))) + +(defn valid-email? [email] + (and (string? email) + (not (empty? email)))) + +(defn valid-code? [code] + (and (string? code) + (re-matches #"^\d{6}$" code))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; phone utils