From 03ff4197be37ecf77521a2a16f8ed06cbd7d3cb0 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Tue, 11 Aug 2026 17:56:00 -0500 Subject: [PATCH 1/4] feat(commons): notify legacy users to reset their password - Show a one-time modal on login and sign-up pointing pre-migration modelingcommons.org users at password reset - Suppress it for logged-in users, after dismissal, and past the sunset date constant - Extract getResetPasswordLink so the modal and ReclaimAccount share one link builder --- .../auth/LegacyAccountNoticeDialog.test.ts | 96 +++++++++++++++++++ .../auth/LegacyAccountNoticeDialog.vue | 54 +++++++++++ .../app/components/auth/ReclaimAccount.vue | 5 +- .../auth/useLegacyAccountNotice.ts | 34 +++++++ .../app/pages/(auth)/login.vue | 1 + .../app/pages/(auth)/signup.vue | 1 + .../app/utils/auth.test.ts | 15 +++ .../app/utils/auth.ts | 5 + 8 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.test.ts create mode 100644 apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.vue create mode 100644 apps/modeling-commons-frontend/app/composables/auth/useLegacyAccountNotice.ts diff --git a/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.test.ts b/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.test.ts new file mode 100644 index 00000000..ee8fd5a0 --- /dev/null +++ b/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.test.ts @@ -0,0 +1,96 @@ +import { mockNuxtImport, mountSuspended } from "@nuxt/test-utils/runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { computed } from "vue"; +import LegacyAccountNoticeDialog from "./LegacyAccountNoticeDialog.vue"; +import { + legacyAccountNoticeDismissalKey, + legacyAccountNoticeSunsetDate, +} from "~/composables/auth/useLegacyAccountNotice"; + +const { userState } = vi.hoisted(() => ({ + userState: { current: { isLoggedIn: false } as { isLoggedIn: boolean } }, +})); + +mockNuxtImport("useUser", () => () => computed(() => userState.current)); + +const noticeTitle = "Your old account is still here"; + +function renderedText() { + return document.body.textContent ?? ""; +} + +beforeEach(() => { + userState.current = { isLoggedIn: false }; + window.localStorage.clear(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-08-11T10:00:00")); +}); + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ""; +}); + +describe("LegacyAccountNoticeDialog", () => { + it("opens for a signed-out visitor before the sunset date", async () => { + await mountSuspended(LegacyAccountNoticeDialog); + + expect(renderedText()).toContain(noticeTitle); + }); + + it("stays closed on the sunset date", async () => { + vi.setSystemTime(new Date(`${legacyAccountNoticeSunsetDate}T00:00:00`)); + + await mountSuspended(LegacyAccountNoticeDialog); + + expect(renderedText()).not.toContain(noticeTitle); + }); + + it("stays closed after the sunset date", async () => { + vi.setSystemTime(new Date("2027-01-05T10:00:00")); + + await mountSuspended(LegacyAccountNoticeDialog); + + expect(renderedText()).not.toContain(noticeTitle); + }); + + it("stays closed once it has been dismissed", async () => { + window.localStorage.setItem(legacyAccountNoticeDismissalKey, "1"); + + await mountSuspended(LegacyAccountNoticeDialog); + + expect(renderedText()).not.toContain(noticeTitle); + }); + + it("stays closed for a signed-in user", async () => { + userState.current = { isLoggedIn: true }; + + await mountSuspended(LegacyAccountNoticeDialog); + + expect(renderedText()).not.toContain(noticeTitle); + }); + + it("persists the dismissal and closes when the visitor dismisses it", async () => { + const wrapper = await mountSuspended(LegacyAccountNoticeDialog); + + const dismissButton = document + .querySelectorAll("button") + .values() + .find((button) => button.textContent?.includes("Maybe later")); + + expect(dismissButton).toBeDefined(); + dismissButton!.click(); + await wrapper.vm.$nextTick(); + + expect(window.localStorage.getItem(legacyAccountNoticeDismissalKey)).toBe("1"); + expect(document.querySelector('[role="dialog"][data-state="open"]')).toBeNull(); + }); + + it("points the primary action at the reset-password route with the next path", async () => { + await mountSuspended(LegacyAccountNoticeDialog, { props: { next: "/models/foo bar" } }); + + const link = document.querySelector('a[href*="reset-password"]'); + + expect(link?.getAttribute("href")).toBe(getResetPasswordLink("/models/foo bar")); + }); +}); diff --git a/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.vue b/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.vue new file mode 100644 index 00000000..8083c2f8 --- /dev/null +++ b/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/modeling-commons-frontend/app/components/auth/ReclaimAccount.vue b/apps/modeling-commons-frontend/app/components/auth/ReclaimAccount.vue index c033c8af..b0477d16 100644 --- a/apps/modeling-commons-frontend/app/components/auth/ReclaimAccount.vue +++ b/apps/modeling-commons-frontend/app/components/auth/ReclaimAccount.vue @@ -22,8 +22,5 @@ const props = defineProps<{ next?: string; }>(); -const resetPasswordLink = computed(() => { - const nextPath = getSafeNextPath(props.next); - return `${authRoutes.resetPassword}?next=${encodeURIComponent(nextPath)}`; -}); +const resetPasswordLink = computed(() => getResetPasswordLink(props.next)); diff --git a/apps/modeling-commons-frontend/app/composables/auth/useLegacyAccountNotice.ts b/apps/modeling-commons-frontend/app/composables/auth/useLegacyAccountNotice.ts new file mode 100644 index 00000000..c82bf0f8 --- /dev/null +++ b/apps/modeling-commons-frontend/app/composables/auth/useLegacyAccountNotice.ts @@ -0,0 +1,34 @@ +/** + * The legacy modelingcommons.org notice stops showing on this date (viewer local time). + * Edit this single constant to move or extend the sunset. + */ +export const legacyAccountNoticeSunsetDate = "2026-09-18"; + +export const legacyAccountNoticeDismissalKey = "auth:legacy-account-notice:dismissed"; + +export function isLegacyAccountNoticeExpired(now: Date = new Date()) { + return now.getTime() >= new Date(`${legacyAccountNoticeSunsetDate}T00:00:00`).getTime(); +} + +export default function useLegacyAccountNotice() { + const user = useUser(); + const open = ref(false); + + function dismiss() { + if (typeof window !== "undefined") { + window.localStorage.setItem(legacyAccountNoticeDismissalKey, "1"); + } + + open.value = false; + } + + onMounted(() => { + if (user.value.isLoggedIn || isLegacyAccountNoticeExpired()) { + return; + } + + open.value = window.localStorage.getItem(legacyAccountNoticeDismissalKey) !== "1"; + }); + + return { open, dismiss }; +} diff --git a/apps/modeling-commons-frontend/app/pages/(auth)/login.vue b/apps/modeling-commons-frontend/app/pages/(auth)/login.vue index a87024b2..89d5a26d 100644 --- a/apps/modeling-commons-frontend/app/pages/(auth)/login.vue +++ b/apps/modeling-commons-frontend/app/pages/(auth)/login.vue @@ -1,5 +1,6 @@