From 12326270fac0d2c4e807ec102389329242e811f9 Mon Sep 17 00:00:00 2001 From: Jim Date: Sat, 26 Sep 2026 22:40:38 -0700 Subject: [PATCH 1/6] Rename project and update deployment workflow Updated the deployment workflow to reflect the new project name and modified steps for building and deploying the frontend and backend. --- .github/workflows/deploy.yml | 148 ++++++++++++++++++++++++----------- 1 file changed, 104 insertions(+), 44 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 812bf23a..77157422 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,13 +1,18 @@ -name: Deploy Schej +name: Deploy Timeful on: + push: + branches: + - main workflow_dispatch: - inputs: - target: - description: "What to deploy?" - required: true - type: choice - options: [server, frontend, both] + +permissions: + contents: read + id-token: write + +concurrency: + group: timeful-production + cancel-in-progress: false jobs: deploy: @@ -15,47 +20,102 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + + # ----------------------------- + # Frontend + # ----------------------------- + + - name: Set up Node + uses: actions/setup-node@v4 with: - ref: ${{ github.ref_name }} + node-version: "18" + cache: npm + cache-dependency-path: frontend/package-lock.json - - name: Prepare SSH - shell: bash + - name: Build frontend + working-directory: frontend + env: + VUE_APP_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }} + VUE_APP_POSTHOG_API_KEY: "" + VUE_APP_MICROSOFT_CLIENT_ID: "" run: | - set -euo pipefail - mkdir -p ~/.ssh - echo "${{ secrets.SSH_PRIVATE_KEY }}" > aws-key - chmod 600 aws-key - ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts + npm ci + npm run build - - name: Ensure deploy scripts are executable - run: chmod +x ./deploy_scripts/*.sh + # ----------------------------- + # Backend + # ----------------------------- - - name: Deploy - shell: bash + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25" + cache-dependency-path: server/go.sum + + - name: Build backend + working-directory: server env: - TARGET: ${{ github.event.inputs.target }} - SSH_USER: ${{ secrets.SSH_USER }} - SSH_HOST: ${{ secrets.SSH_HOST }} + CGO_ENABLED: "0" + GOOS: linux + GOARCH: amd64 + run: | + go build \ + -buildvcs=false \ + -trimpath \ + -ldflags="-s -w" \ + -o ../timeful-server \ + . + + # ----------------------------- + # Release archive + # ----------------------------- + + - name: Package release + run: | + mkdir -p release/frontend + + cp timeful-server release/timeful-server + cp -a frontend/dist release/frontend/dist + + chmod +x release/timeful-server + + tar -C release -czf timeful-release.tar.gz . + + # ----------------------------- + # Authenticate to Google Cloud + # ----------------------------- + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + project_id: ${{ vars.GCP_PROJECT_ID }} + workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v3 + + # ----------------------------- + # Deploy through IAP + # ----------------------------- + + - name: Upload release + run: | + gcloud compute scp \ + timeful-release.tar.gz \ + "${{ vars.GCP_VM }}:/tmp/timeful-release.tar.gz" \ + --project="${{ vars.GCP_PROJECT_ID }}" \ + --zone="${{ vars.GCP_ZONE }}" \ + --tunnel-through-iap \ + --quiet + + - name: Activate release run: | - set -euo pipefail - DEPLOY_SCRIPT_DIRECTORY="./deploy_scripts/" - HOST="${SSH_USER}@${SSH_HOST}" - - if [ "$TARGET" = "server" ]; then - DEPLOY_SCRIPT="deploy_server.sh" - elif [ "$TARGET" = "frontend" ]; then - DEPLOY_SCRIPT="deploy_frontend.sh" - elif [ "$TARGET" = "both" ]; then - for DEPLOY_SCRIPT in deploy_frontend.sh deploy_server.sh; do - echo "${DEPLOY_SCRIPT_DIRECTORY}${DEPLOY_SCRIPT}" - /bin/bash "${DEPLOY_SCRIPT_DIRECTORY}${DEPLOY_SCRIPT}" "$HOST" "./aws-key" - done - exit 0 - else - echo "Invalid script name!" - exit 1 - fi - - echo "${DEPLOY_SCRIPT_DIRECTORY}${DEPLOY_SCRIPT}" - /bin/bash "${DEPLOY_SCRIPT_DIRECTORY}${DEPLOY_SCRIPT}" "$HOST" "./aws-key" + gcloud compute ssh \ + "${{ vars.GCP_VM }}" \ + --project="${{ vars.GCP_PROJECT_ID }}" \ + --zone="${{ vars.GCP_ZONE }}" \ + --tunnel-through-iap \ + --quiet \ + --command="sudo /usr/local/sbin/deploy-timeful /tmp/timeful-release.tar.gz && rm -f /tmp/timeful-release.tar.gz" From f0780204bcea7f712100d13244061f2c51da31ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:16:28 +0000 Subject: [PATCH 2/6] Add email/domain allowlist and remove paywall gating Co-authored-by: jimfangx <23324711+jimfangx@users.noreply.github.com> --- frontend/src/store/index.js | 20 ++-------- frontend/src/utils/general_utils.js | 10 +---- server/.env.template | 9 ++++- server/routes/auth.go | 62 +++++++++++++++++++++++++++-- server/routes/users.go | 12 +----- 5 files changed, 72 insertions(+), 41 deletions(-) diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index a0770841..66edc282 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -1,6 +1,6 @@ import Vue from "vue" import Vuex from "vuex" -import { numFreeEvents, upgradeDialogTypes } from "@/constants" +import { upgradeDialogTypes } from "@/constants" import { get, isPremiumUser } from "@/utils" import { createFolder, @@ -29,7 +29,7 @@ export default new Vuex.Store({ signUpFormEnabled: false, daysOnlyEnabled: true, overlayAvailabilitiesEnabled: true, - enablePaywall: true, + enablePaywall: false, // Experiments pricingPageConversion: "control", @@ -163,21 +163,7 @@ export default new Vuex.Store({ commit("setAuthUser", authUser) }, - createNew( - { state, getters, commit, dispatch }, - { eventOnly = false, folderId = null } - ) { - if ( - state.enablePaywall && - !getters.isPremiumUser && - state.authUser?.numEventsCreated >= numFreeEvents - ) { - dispatch("showUpgradeDialog", { - type: upgradeDialogTypes.CREATE_EVENT, - }) - return - } - + createNew({ commit }, { eventOnly = false, folderId = null }) { commit("setNewDialogOptions", { show: true, contactsPayload: {}, diff --git a/frontend/src/utils/general_utils.js b/frontend/src/utils/general_utils.js index 72b1d693..dee0d9d3 100644 --- a/frontend/src/utils/general_utils.js +++ b/frontend/src/utils/general_utils.js @@ -215,15 +215,7 @@ export const lightOrDark = (color) => { /** Returns whether the given user is a premium user */ export const isPremiumUser = (authUser) => { - if (!authUser) return false - - if (authUser.stripeCustomerId) { - if (authUser.isPremium !== null) { - return authUser.isPremium - } - return true - } - return false + return !!authUser } /** Adds an event ID to the 'eventsCreated' list in localStorage */ diff --git a/server/.env.template b/server/.env.template index 84618e52..df6efbdc 100644 --- a/server/.env.template +++ b/server/.env.template @@ -55,4 +55,11 @@ SESSION_SECRET=? # CORS # - Comma-separated list of allowed origins # - For local development, set to: http://localhost:8080 -CORS_ORIGINS=? \ No newline at end of file +CORS_ORIGINS=? + +# Auth allowlist (optional) +# - If both are empty, sign-in is unrestricted. +# - ALLOWED_EMAILS: exact email allowlist. +# - ALLOWED_EMAIL_DOMAINS: domain allowlist (e.g. berkeley.edu). +ALLOWED_EMAILS=? # optional +ALLOWED_EMAIL_DOMAINS=? # optional \ No newline at end of file diff --git a/server/routes/auth.go b/server/routes/auth.go index a894a1bb..8dc8a289 100644 --- a/server/routes/auth.go +++ b/server/routes/auth.go @@ -4,6 +4,7 @@ package routes import ( "context" "crypto/rand" + "errors" "fmt" "math/big" "net/http" @@ -30,6 +31,8 @@ import ( "schej.it/server/utils" ) +var errEmailNotAllowed = errors.New("email-not-allowed") + func InitAuth(router *gin.RouterGroup) { authRouter := router.Group("/auth") @@ -43,6 +46,36 @@ func InitAuth(router *gin.RouterGroup) { authRouter.POST("/otp/verify", verifyOtp) } +func isAllowedEmail(email string) bool { + email = utils.NormalizeEmail(email) + + rawEmails := os.Getenv("ALLOWED_EMAILS") + rawDomains := os.Getenv("ALLOWED_EMAIL_DOMAINS") + if rawEmails == "" && rawDomains == "" { + // Empty means unrestricted. + return true + } + + for _, allowed := range strings.Split(rawEmails, ",") { + if email == utils.NormalizeEmail(allowed) { + return true + } + } + + atIdx := strings.LastIndex(email, "@") + if atIdx == -1 || atIdx == len(email)-1 { + return false + } + emailDomain := email[atIdx+1:] + for _, allowedDomain := range strings.Split(rawDomains, ",") { + if emailDomain == utils.NormalizeEmail(allowedDomain) { + return true + } + } + + return false +} + // @Summary Signs user in // @Description Signs user in and sets the access token session variable // @Tags auth @@ -67,6 +100,10 @@ func signIn(c *gin.Context) { user, err := signInHelper(c, tokens, models.WEB, payload.CalendarType, *payload.TimezoneOffset) if err != nil { + if errors.Is(err, errEmailNotAllowed) { + c.JSON(http.StatusForbidden, gin.H{"error": "email-not-allowed"}) + return + } c.JSON(http.StatusUnauthorized, responses.Error{Error: errs.InvalidIdToken}) return } @@ -119,6 +156,10 @@ func signInMobile(c *gin.Context) { payload.TimezoneOffset, ) if err != nil { + if errors.Is(err, errEmailNotAllowed) { + c.JSON(http.StatusForbidden, gin.H{"error": "email-not-allowed"}) + return + } c.JSON(http.StatusUnauthorized, responses.Error{Error: errs.InvalidIdToken}) return } @@ -170,6 +211,9 @@ func signInHelper(c *gin.Context, token auth.TokenResponse, tokenOrigin models.T picture = "" } email = utils.NormalizeEmail(email) + if !isAllowedEmail(email) { + return models.User{}, errEmailNotAllowed + } primaryAccountKey := utils.GetCalendarAccountKey(email, calendarType) @@ -344,7 +388,11 @@ func checkEmail(c *gin.Context) { return } - email := strings.ToLower(strings.TrimSpace(payload.Email)) + email := utils.NormalizeEmail(payload.Email) + if !isAllowedEmail(email) { + c.JSON(http.StatusForbidden, gin.H{"error": "email-not-allowed"}) + return + } isNewUser := db.GetUserByEmail(email) == nil c.JSON(http.StatusOK, gin.H{"isNewUser": isNewUser}) @@ -365,7 +413,11 @@ func sendOtp(c *gin.Context) { return } - email := strings.ToLower(strings.TrimSpace(payload.Email)) + email := utils.NormalizeEmail(payload.Email) + if !isAllowedEmail(email) { + c.JSON(http.StatusForbidden, gin.H{"error": "email-not-allowed"}) + return + } // Delete any existing OTP codes for this email db.OtpCodesCollection.DeleteMany(context.Background(), bson.M{"email": email}) @@ -414,7 +466,11 @@ func verifyOtp(c *gin.Context) { return } - email := strings.ToLower(strings.TrimSpace(payload.Email)) + email := utils.NormalizeEmail(payload.Email) + if !isAllowedEmail(email) { + c.JSON(http.StatusForbidden, gin.H{"error": "email-not-allowed"}) + return + } // Find the OTP document var otpDoc models.OtpCode diff --git a/server/routes/users.go b/server/routes/users.go index 715a050d..06397cc0 100644 --- a/server/routes/users.go +++ b/server/routes/users.go @@ -32,17 +32,7 @@ func getIsUserPremium(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"isPremium": false}) return } - - isPremium := false - if user.StripeCustomerId != nil { - if user.IsPremium != nil { - isPremium = *user.IsPremium - } else { - isPremium = true - } - } - - c.JSON(http.StatusOK, gin.H{"isPremium": isPremium}) + c.JSON(http.StatusOK, gin.H{"isPremium": true}) } // @Summary Returns a minimal public user profile (safe for unauthenticated clients) From e53737da36051bc6a274da45656c40351c38a2b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:20:11 +0000 Subject: [PATCH 3/6] Cache allowlist parsing and add auth allowlist tests Co-authored-by: jimfangx <23324711+jimfangx@users.noreply.github.com> --- server/go.mod | 2 +- server/go.sum | 10 ---- server/routes/auth.go | 89 +++++++++++++++++++++++----- server/routes/auth_allowlist_test.go | 39 ++++++++++++ 4 files changed, 113 insertions(+), 27 deletions(-) create mode 100644 server/routes/auth_allowlist_test.go diff --git a/server/go.mod b/server/go.mod index 2cbc9960..85273532 100644 --- a/server/go.mod +++ b/server/go.mod @@ -21,7 +21,7 @@ require ( gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) -require github.com/stripe/stripe-go/v82 v82.0.0 // indirect +require github.com/stripe/stripe-go/v82 v82.0.0 require ( cloud.google.com/go/compute v1.23.3 // indirect diff --git a/server/go.sum b/server/go.sum index 500c2fca..84cbf3e5 100644 --- a/server/go.sum +++ b/server/go.sum @@ -10,8 +10,6 @@ cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXE github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/brianvoe/sjwt v0.5.1 h1:OKwnUrrVMnP81N9S5+ylgZECUEwW4Uw6W6J0FgcIZfw= @@ -89,7 +87,6 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91 github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjAR7VgKoNB6ryXfw= github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= @@ -187,7 +184,6 @@ github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZO github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -256,8 +252,6 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -277,8 +271,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -303,8 +295,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/server/routes/auth.go b/server/routes/auth.go index 8dc8a289..627a9e9f 100644 --- a/server/routes/auth.go +++ b/server/routes/auth.go @@ -11,6 +11,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "github.com/gin-contrib/sessions" @@ -31,7 +32,18 @@ import ( "schej.it/server/utils" ) -var errEmailNotAllowed = errors.New("email-not-allowed") +var errEmailNotAllowed = errors.New("email is not allowed for this deployment") + +var allowedEmailState = struct { + sync.RWMutex + rawEmails string + rawDomains string + allowedEmails map[string]struct{} + allowedEmailTlds map[string]struct{} +}{ + allowedEmails: map[string]struct{}{}, + allowedEmailTlds: map[string]struct{}{}, +} func InitAuth(router *gin.RouterGroup) { authRouter := router.Group("/auth") @@ -51,15 +63,18 @@ func isAllowedEmail(email string) bool { rawEmails := os.Getenv("ALLOWED_EMAILS") rawDomains := os.Getenv("ALLOWED_EMAIL_DOMAINS") - if rawEmails == "" && rawDomains == "" { + refreshAllowlistCache(rawEmails, rawDomains) + + allowedEmailState.RLock() + defer allowedEmailState.RUnlock() + + if len(allowedEmailState.allowedEmails) == 0 && len(allowedEmailState.allowedEmailTlds) == 0 { // Empty means unrestricted. return true } - for _, allowed := range strings.Split(rawEmails, ",") { - if email == utils.NormalizeEmail(allowed) { - return true - } + if _, ok := allowedEmailState.allowedEmails[email]; ok { + return true } atIdx := strings.LastIndex(email, "@") @@ -67,13 +82,40 @@ func isAllowedEmail(email string) bool { return false } emailDomain := email[atIdx+1:] - for _, allowedDomain := range strings.Split(rawDomains, ",") { - if emailDomain == utils.NormalizeEmail(allowedDomain) { - return true - } + _, ok := allowedEmailState.allowedEmailTlds[emailDomain] + return ok +} + +func refreshAllowlistCache(rawEmails string, rawDomains string) { + allowedEmailState.RLock() + if rawEmails == allowedEmailState.rawEmails && rawDomains == allowedEmailState.rawDomains { + allowedEmailState.RUnlock() + return } + allowedEmailState.RUnlock() - return false + allowedEmailState.Lock() + defer allowedEmailState.Unlock() + + if rawEmails == allowedEmailState.rawEmails && rawDomains == allowedEmailState.rawDomains { + return + } + + allowedEmailState.rawEmails = rawEmails + allowedEmailState.rawDomains = rawDomains + allowedEmailState.allowedEmails = parseAllowlist(rawEmails) + allowedEmailState.allowedEmailTlds = parseAllowlist(rawDomains) +} + +func parseAllowlist(raw string) map[string]struct{} { + result := make(map[string]struct{}) + for _, entry := range strings.Split(raw, ",") { + normalized := utils.NormalizeEmail(entry) + if normalized != "" { + result[normalized] = struct{}{} + } + } + return result } // @Summary Signs user in @@ -101,7 +143,10 @@ func signIn(c *gin.Context) { user, err := signInHelper(c, tokens, models.WEB, payload.CalendarType, *payload.TimezoneOffset) if err != nil { if errors.Is(err, errEmailNotAllowed) { - c.JSON(http.StatusForbidden, gin.H{"error": "email-not-allowed"}) + c.JSON(http.StatusForbidden, gin.H{ + "error": "email-not-allowed", + "message": "sign-in is restricted for this email address", + }) return } c.JSON(http.StatusUnauthorized, responses.Error{Error: errs.InvalidIdToken}) @@ -157,7 +202,10 @@ func signInMobile(c *gin.Context) { ) if err != nil { if errors.Is(err, errEmailNotAllowed) { - c.JSON(http.StatusForbidden, gin.H{"error": "email-not-allowed"}) + c.JSON(http.StatusForbidden, gin.H{ + "error": "email-not-allowed", + "message": "sign-in is restricted for this email address", + }) return } c.JSON(http.StatusUnauthorized, responses.Error{Error: errs.InvalidIdToken}) @@ -390,7 +438,10 @@ func checkEmail(c *gin.Context) { email := utils.NormalizeEmail(payload.Email) if !isAllowedEmail(email) { - c.JSON(http.StatusForbidden, gin.H{"error": "email-not-allowed"}) + c.JSON(http.StatusForbidden, gin.H{ + "error": "email-not-allowed", + "message": "sign-in is restricted for this email address", + }) return } isNewUser := db.GetUserByEmail(email) == nil @@ -415,7 +466,10 @@ func sendOtp(c *gin.Context) { email := utils.NormalizeEmail(payload.Email) if !isAllowedEmail(email) { - c.JSON(http.StatusForbidden, gin.H{"error": "email-not-allowed"}) + c.JSON(http.StatusForbidden, gin.H{ + "error": "email-not-allowed", + "message": "sign-in is restricted for this email address", + }) return } @@ -468,7 +522,10 @@ func verifyOtp(c *gin.Context) { email := utils.NormalizeEmail(payload.Email) if !isAllowedEmail(email) { - c.JSON(http.StatusForbidden, gin.H{"error": "email-not-allowed"}) + c.JSON(http.StatusForbidden, gin.H{ + "error": "email-not-allowed", + "message": "sign-in is restricted for this email address", + }) return } diff --git a/server/routes/auth_allowlist_test.go b/server/routes/auth_allowlist_test.go new file mode 100644 index 00000000..e9e46fff --- /dev/null +++ b/server/routes/auth_allowlist_test.go @@ -0,0 +1,39 @@ +package routes + +import "testing" + +func TestIsAllowedEmail_Unrestricted(t *testing.T) { + t.Setenv("ALLOWED_EMAILS", "") + t.Setenv("ALLOWED_EMAIL_DOMAINS", "") + + if !isAllowedEmail("anyone@example.com") { + t.Fatal("expected unrestricted mode to allow any email") + } +} + +func TestIsAllowedEmail_AllowsExactEmail(t *testing.T) { + t.Setenv("ALLOWED_EMAILS", "Person@One.com, someone@else.com") + t.Setenv("ALLOWED_EMAIL_DOMAINS", "") + + if !isAllowedEmail("person@one.com") { + t.Fatal("expected exact email allowlist match") + } +} + +func TestIsAllowedEmail_AllowsDomain(t *testing.T) { + t.Setenv("ALLOWED_EMAILS", "") + t.Setenv("ALLOWED_EMAIL_DOMAINS", "berkeley.edu, example.org") + + if !isAllowedEmail("user@berkeley.edu") { + t.Fatal("expected domain allowlist match") + } +} + +func TestIsAllowedEmail_RejectsUnknownEmail(t *testing.T) { + t.Setenv("ALLOWED_EMAILS", "allowed@other.com") + t.Setenv("ALLOWED_EMAIL_DOMAINS", "berkeley.edu") + + if isAllowedEmail("blocked@example.com") { + t.Fatal("expected unknown email and domain to be rejected") + } +} From 0ed4e0f945f53eb818b8c603891419240f0511dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:21:18 +0000 Subject: [PATCH 4/6] Isolate allowlist cache in auth tests Co-authored-by: jimfangx <23324711+jimfangx@users.noreply.github.com> --- server/routes/auth_allowlist_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/routes/auth_allowlist_test.go b/server/routes/auth_allowlist_test.go index e9e46fff..dd280d98 100644 --- a/server/routes/auth_allowlist_test.go +++ b/server/routes/auth_allowlist_test.go @@ -2,7 +2,17 @@ package routes import "testing" +func resetAllowedEmailStateForTests() { + allowedEmailState.Lock() + defer allowedEmailState.Unlock() + allowedEmailState.rawEmails = "" + allowedEmailState.rawDomains = "" + allowedEmailState.allowedEmails = map[string]struct{}{} + allowedEmailState.allowedEmailTlds = map[string]struct{}{} +} + func TestIsAllowedEmail_Unrestricted(t *testing.T) { + resetAllowedEmailStateForTests() t.Setenv("ALLOWED_EMAILS", "") t.Setenv("ALLOWED_EMAIL_DOMAINS", "") @@ -12,6 +22,7 @@ func TestIsAllowedEmail_Unrestricted(t *testing.T) { } func TestIsAllowedEmail_AllowsExactEmail(t *testing.T) { + resetAllowedEmailStateForTests() t.Setenv("ALLOWED_EMAILS", "Person@One.com, someone@else.com") t.Setenv("ALLOWED_EMAIL_DOMAINS", "") @@ -21,6 +32,7 @@ func TestIsAllowedEmail_AllowsExactEmail(t *testing.T) { } func TestIsAllowedEmail_AllowsDomain(t *testing.T) { + resetAllowedEmailStateForTests() t.Setenv("ALLOWED_EMAILS", "") t.Setenv("ALLOWED_EMAIL_DOMAINS", "berkeley.edu, example.org") @@ -30,6 +42,7 @@ func TestIsAllowedEmail_AllowsDomain(t *testing.T) { } func TestIsAllowedEmail_RejectsUnknownEmail(t *testing.T) { + resetAllowedEmailStateForTests() t.Setenv("ALLOWED_EMAILS", "allowed@other.com") t.Setenv("ALLOWED_EMAIL_DOMAINS", "berkeley.edu") From 66cd1e47584d9a744cf68f3b970974af05a328d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:22:33 +0000 Subject: [PATCH 5/6] Rename allowlist domain cache field for clarity Co-authored-by: jimfangx <23324711+jimfangx@users.noreply.github.com> --- server/routes/auth.go | 18 +++++++++--------- server/routes/auth_allowlist_test.go | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/routes/auth.go b/server/routes/auth.go index 627a9e9f..4b573105 100644 --- a/server/routes/auth.go +++ b/server/routes/auth.go @@ -36,13 +36,13 @@ var errEmailNotAllowed = errors.New("email is not allowed for this deployment") var allowedEmailState = struct { sync.RWMutex - rawEmails string - rawDomains string - allowedEmails map[string]struct{} - allowedEmailTlds map[string]struct{} + rawEmails string + rawDomains string + allowedEmails map[string]struct{} + allowedDomains map[string]struct{} }{ - allowedEmails: map[string]struct{}{}, - allowedEmailTlds: map[string]struct{}{}, + allowedEmails: map[string]struct{}{}, + allowedDomains: map[string]struct{}{}, } func InitAuth(router *gin.RouterGroup) { @@ -68,7 +68,7 @@ func isAllowedEmail(email string) bool { allowedEmailState.RLock() defer allowedEmailState.RUnlock() - if len(allowedEmailState.allowedEmails) == 0 && len(allowedEmailState.allowedEmailTlds) == 0 { + if len(allowedEmailState.allowedEmails) == 0 && len(allowedEmailState.allowedDomains) == 0 { // Empty means unrestricted. return true } @@ -82,7 +82,7 @@ func isAllowedEmail(email string) bool { return false } emailDomain := email[atIdx+1:] - _, ok := allowedEmailState.allowedEmailTlds[emailDomain] + _, ok := allowedEmailState.allowedDomains[emailDomain] return ok } @@ -104,7 +104,7 @@ func refreshAllowlistCache(rawEmails string, rawDomains string) { allowedEmailState.rawEmails = rawEmails allowedEmailState.rawDomains = rawDomains allowedEmailState.allowedEmails = parseAllowlist(rawEmails) - allowedEmailState.allowedEmailTlds = parseAllowlist(rawDomains) + allowedEmailState.allowedDomains = parseAllowlist(rawDomains) } func parseAllowlist(raw string) map[string]struct{} { diff --git a/server/routes/auth_allowlist_test.go b/server/routes/auth_allowlist_test.go index dd280d98..ef5f3df8 100644 --- a/server/routes/auth_allowlist_test.go +++ b/server/routes/auth_allowlist_test.go @@ -8,7 +8,7 @@ func resetAllowedEmailStateForTests() { allowedEmailState.rawEmails = "" allowedEmailState.rawDomains = "" allowedEmailState.allowedEmails = map[string]struct{}{} - allowedEmailState.allowedEmailTlds = map[string]struct{}{} + allowedEmailState.allowedDomains = map[string]struct{}{} } func TestIsAllowedEmail_Unrestricted(t *testing.T) { From dfa7e2514dff52dcb612d852c9d1d1e7f9c84b06 Mon Sep 17 00:00:00 2001 From: Jim Fang Date: Sun, 27 Sep 2026 00:12:41 -0700 Subject: [PATCH 6/6] Add dark mode Adds a light/dark toggle in the header that persists to localStorage and defaults to the OS/browser color scheme preference on first visit. The neutral Tailwind colors (white, off-white, black, gray, dark-gray, very-dark-gray, light-gray, light-gray-stroke) are now backed by CSS variables that flip when the `dark` class is applied to , so existing tw-bg-white/tw-text-black/etc. usage across the app adapts automatically. Vuetify's theme.dark is kept in sync so built-in components (dialogs, menus, text fields, date pickers) theme correctly too. An inline script in index.html applies the class before first paint to avoid a flash of the wrong theme. Co-Authored-By: Claude Sonnet 5 --- frontend/public/index.html | 12 +++++++++++ frontend/src/App.vue | 26 +++++++++++++++++++++++ frontend/src/components/AuthUserMenu.vue | 4 ++-- frontend/src/index.css | 27 ++++++++++++++++++++++++ frontend/src/plugins/vuetify.js | 8 +++++++ frontend/src/store/index.js | 8 ++++++- frontend/src/utils/general_utils.js | 13 ++++++++++++ frontend/tailwind.config.js | 22 ++++++++++++------- 8 files changed, 109 insertions(+), 11 deletions(-) diff --git a/frontend/public/index.html b/frontend/public/index.html index a300a8a4..8b08dcbb 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,6 +1,18 @@ + +