diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8926bb53..37809e534 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,10 +7,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup node - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: 16 cache: yarn @@ -33,10 +33,10 @@ jobs: steps: # Downloads a copy of the code in your repository before running CI tests - name: Check out repository code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup node - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: 16 cache: yarn @@ -55,24 +55,18 @@ jobs: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=password -e POSTGRES_DB=db postgres:14.2 yarn wait-on --timeout 60000 tcp:localhost:5432 - - name: Build - run: yarn build - - name: Deploy migrations run: yarn prisma migrate deploy - name: Install playwright dependencies run: yarn playwright install --with-deps chromium - - name: Launch app - run: yarn start 2>&1 & # backgrounnd mode - - name: Run tests run: yarn test - name: Upload artifact playwright-report if: ${{ success() || failure() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: playwright-report path: ./playwright-report diff --git a/declarations/smpt-tester.d.ts b/declarations/smpt-tester.d.ts new file mode 100644 index 000000000..2b5254b21 --- /dev/null +++ b/declarations/smpt-tester.d.ts @@ -0,0 +1,111 @@ +declare module "smtp-tester" { + /** + * Initializes the SMTP tester. + * + * @param port The port of the SMTP server. + */ + function init(port: number): SmtpTester; + + /** + * A callback that occurs when an email is received. + * + * @param recipient The bound recipient. Can be `undefined` if the handler is not bound to a specific recipient. + * @param id The local incrementing identifier of the email. + * @param email The email being received. + */ + type OnReceiveEmail = ( + recipient: string, + id: number, + email: EmailInfo, + ) => void; + + type CaptureOneResponse = { + address: string; + id: string; + email: EmailInfo; + }; + /** + * The SMTP tester. + */ + interface SmtpTester { + /** + * Binds a callback to a specific recipient that is fired whenever an email is received for that specific recipient. + * + * @param recipient The recipient to bind to. + * @param callback The callback function. + */ + bind(recipient: string, callback: OnReceiveEmail): void; + + /** + * Binds a callback that is fired whenever an email is received. + * + * @param callback The callback function. + */ + bind(callback: OnReceiveEmail): void; + + /** + * Captures the next email received by the server. + * + * @param recipient The recipient to capture for. If not specified, the next email received will be captured. + * @param options The options for the capture. + */ + captureOne( + recipient: string, + options?: CaptureOptions, + ): Promise; + + /** + * Stops the running SMTP server. + */ + stop(): void; + + /** + * Stops the running SMTP server. + * + * @param callback The callback that is fired when the server has stopped. + */ + stop(callback: () => void): void; + } + + /** + * Contains information about a received email. + */ + interface EmailInfo { + /** + * The sender of the email. + */ + readonly sender: string; + + /** + * The body of the email. + */ + readonly body: string; + + /** + * The HTML body of the email. + */ + readonly html: string; + + /** + * Headers of the email. + */ + readonly headers: { + /** + * Who the email was sent from. + */ + from: string; + + /** + * Who the email was sent to. + */ + to: string; + + /** + * The subject of the email. + */ + subject: string; + + [string]: string; + }; + } +} diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 000000000..910a75718 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,26 @@ +services: + rallly_db: + image: postgres:14.2 + restart: always + environment: + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=db + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + rallly: + build: + context: . + restart: always + depends_on: + rallly_db: + condition: service_healthy + ports: + - 3000:3000 + environment: + - DATABASE_URL=postgres://postgres:postgres@rallly_db:5432/db + - NODE_ENV=test + - SECRET_PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890 diff --git a/package.json b/package.json index 380addbf7..a8aa952a8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "lint": "eslint .", "lint:tsc": "tsc --noEmit", "lint:i18n": "i18n-unused remove-unused", - "test": "playwright test" + "test": "PORT=3001 playwright test", + "test:codegen": "playwright codegen http://localhost:3000" }, "dependencies": { "@floating-ui/react-dom-interactions": "^0.4.0", @@ -73,7 +74,7 @@ "zod": "^3.16.0" }, "devDependencies": { - "@playwright/test": "^1.28.1", + "@playwright/test": "^1.30.0", "@types/accept-language-parser": "^1.5.3", "@types/lodash": "^4.14.178", "@types/nodemailer": "^6.4.4", @@ -84,6 +85,7 @@ "@types/smoothscroll-polyfill": "^0.3.1", "@typescript-eslint/eslint-plugin": "^5.21.0", "@typescript-eslint/parser": "^5.21.0", + "cheerio": "^1.0.0-rc.12", "eslint": "^7.26.0", "eslint-config-next": "^13.0.1", "eslint-import-resolver-typescript": "^2.7.0", @@ -94,6 +96,7 @@ "i18n-unused": "^0.12.0", "prettier": "^2.3.0", "prettier-plugin-tailwindcss": "^0.1.8", + "smtp-tester": "^2.0.1", "wait-on": "^6.0.1" } } diff --git a/playwright.config.ts b/playwright.config.ts index 16036e09e..abddc4009 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,12 @@ import { devices, PlaywrightTestConfig } from "@playwright/test"; const ci = process.env.CI === "true"; +// Use process.env.PORT by default and fallback to port 3000 +const PORT = process.env.PORT || 3000; + +// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port +const baseURL = `http://localhost:${PORT}`; + // Reference: https://playwright.dev/docs/test-configuration const config: PlaywrightTestConfig = { // Artifacts folder where screenshots, videos, and traces are stored. @@ -9,9 +15,15 @@ const config: PlaywrightTestConfig = { projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], use: { viewport: { width: 1280, height: 720 }, - baseURL: "http://localhost:3000", + baseURL, trace: "retain-on-failure", }, + webServer: { + command: `NODE_ENV=test yarn dev --port ${PORT}`, + url: baseURL, + timeout: 120 * 1000, + reuseExistingServer: !ci, + }, reporter: [ [ci ? "github" : "list"], ["html", { open: !ci ? "on-failure" : "never" }], diff --git a/public/locales/ca/app.json b/public/locales/ca/app.json index 36553522e..a6533b5d8 100644 --- a/public/locales/ca/app.json +++ b/public/locales/ca/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Cafeteria Joe", "lockPoll": "Bloquejar enquesta", "login": "Iniciar sessió", - "loginCheckInbox": "Si us plau, comprova la teva bústia.", - "loginMagicLinkSent": "Se t'ha enviat un enllaç màgic a:", - "loginSendMagicLink": "Envia'm un enllaç màgic", - "loginViaMagicLink": "Iniciar sessió amb enllaç màgic", - "loginViaMagicLinkDescription": "T'enviarem un correu electrònic amb un enllaç màgic que pot utilitzar per iniciar sessió.", - "loginWithValidEmail": "Si us plau, introdueix una adreça de correu electrònic vàlida", "logout": "Tanca sessió", "manage": "Gestionar", "menu": "Menú", @@ -97,7 +91,6 @@ "possibleAnswers": "Possibles respostes", "preferences": "Preferències", "previousMonth": "Mes anterior", - "profileLogin": "Perfil - Iniciar sessió", "profileUser": "Perfil - {{username}}", "requiredNameError": "El nom és obligatori", "save": "Desa", diff --git a/public/locales/cs/app.json b/public/locales/cs/app.json index 3f1468e4c..0fbddb20b 100644 --- a/public/locales/cs/app.json +++ b/public/locales/cs/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Příjemná kavárna v centru", "lockPoll": "Zamknout anketu", "login": "Přihlásit", - "loginCheckInbox": "Zkontrolujte si prosím svou e-mailovou schránku.", - "loginMagicLinkSent": "Kouzelný odkaz byl zaslán na:", - "loginSendMagicLink": "Zaslat kouzelný odkaz", - "loginViaMagicLink": "Přihlásit se pomocí kouzelného odkazu", - "loginViaMagicLinkDescription": "Pošleme vám e-mail s kouzelným odkazem, kterým se můžete do aplikace přihlásit.", - "loginWithValidEmail": "Zadejte platnou e-mailovou adresu", "logout": "Odhlásit se", "manage": "Spravovat", "menu": "Menu", @@ -97,7 +91,6 @@ "possibleAnswers": "Možné odpovědi", "preferences": "Předvolby", "previousMonth": "Předchozí měsíc", - "profileLogin": "Profil - přihlášení", "profileUser": "Profil - {{username}}", "requiredNameError": "Jméno je vyžadováno", "save": "Uložit", diff --git a/public/locales/da/app.json b/public/locales/da/app.json index 1df47a40e..8340651a2 100644 --- a/public/locales/da/app.json +++ b/public/locales/da/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Joe's Kaffebutik", "lockPoll": "Lås afstemning", "login": "Log ind", - "loginCheckInbox": "Tjek venligst din indbakke.", - "loginMagicLinkSent": "Et magisk link er blevet sendt til:", - "loginSendMagicLink": "Send mig et magisk link", - "loginViaMagicLink": "Log ind via magisk link", - "loginViaMagicLinkDescription": "Vi sender dig en e-mail med et magisk link, som du kan bruge til at logge ind.", - "loginWithValidEmail": "Angiv venligst en gyldig e-mailadresse", "logout": "Log ud", "manage": "Administrér", "menu": "Menu", @@ -93,7 +87,6 @@ "possibleAnswers": "Mulige svar", "preferences": "Indstillinger", "previousMonth": "Forrige måned", - "profileLogin": "Profil - Login", "profileUser": "Profil - {{username}}", "requiredNameError": "Navn er påkrævet", "save": "Gem", diff --git a/public/locales/de/app.json b/public/locales/de/app.json index f5416ff41..615afb26e 100644 --- a/public/locales/de/app.json +++ b/public/locales/de/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Joe's Café", "lockPoll": "Umfragen sperren", "login": "Login", - "loginCheckInbox": "Bitte überprüfe deinen Posteingang.", - "loginMagicLinkSent": "Ein magischer Link wurde geschickt an:", - "loginSendMagicLink": "Schicke mir einen magischen Link", - "loginViaMagicLink": "Anmeldung über magischen Link", - "loginViaMagicLinkDescription": "Wir senden dir eine E-Mail mit einem magischen Link, mit dem du dich anmelden kannst.", - "loginWithValidEmail": "Bitte gib eine gültige E-Mail-Adresse ein", "logout": "Logout", "manage": "Verwalten", "menu": "Menü", @@ -97,7 +91,6 @@ "possibleAnswers": "Mögliche Antworten", "preferences": "Einstellungen", "previousMonth": "Vorheriger Monat", - "profileLogin": "Profil - Login", "profileUser": "Profil - {{username}}", "requiredNameError": "Bitte gib einen Namen an", "save": "Speichern", diff --git a/public/locales/en/app.json b/public/locales/en/app.json index 2ff84ea97..4d47780fa 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -3,6 +3,7 @@ "24h": "24-hour", "addParticipant": "Add participant", "addTimeOption": "Add time option", + "alreadyRegistered": "Already registered? Login →", "alreadyVoted": "You have already voted", "applyToAllDates": "Apply to all dates", "areYouSure": "Are you sure?", @@ -16,6 +17,7 @@ "continue": "Continue", "copied": "Copied", "copyLink": "Copy link", + "createAnAccount": "Create an account", "createdBy": "by {{name}}", "createPoll": "Create poll", "creatingDemo": "Creating demo poll…", @@ -53,12 +55,6 @@ "locationPlaceholder": "Joe's Coffee Shop", "lockPoll": "Lock poll", "login": "Login", - "loginCheckInbox": "Please check your inbox.", - "loginMagicLinkSent": "A magic link has been sent to:", - "loginSendMagicLink": "Send me a magic link", - "loginViaMagicLink": "Login via magic link", - "loginViaMagicLinkDescription": "We'll send you an email with a magic link that you can use to login.", - "loginWithValidEmail": "Please enter a valid email address", "logout": "Logout", "manage": "Manage", "menu": "Menu", @@ -81,15 +77,16 @@ "notificationsOn": "Notifications are on", "notificationsOnDescription": "An email will be sent to {{email}} when there is activity on this poll.", "notificationsVerifyEmail": "You need to verify your email to turn on notifications", + "notRegistered": "Create a new account →", "noVotes": "No one has voted for this option", "ok": "Ok", "participant": "Participant", - "participantCount_zero": "{{count}} participants", - "participantCount_one": "{{count}} participant", - "participantCount_two": "{{count}} participants", "participantCount_few": "{{count}} participants", "participantCount_many": "{{count}} participants", + "participantCount_one": "{{count}} participant", "participantCount_other": "{{count}} participants", + "participantCount_two": "{{count}} participants", + "participantCount_zero": "{{count}} participants", "pollHasBeenLocked": "This poll has been locked", "pollHasBeenVerified": "Your poll has been verified", "pollOwnerNotice": "Hey {{name}}, looks like you are the owner of this poll.", @@ -97,9 +94,10 @@ "possibleAnswers": "Possible answers", "preferences": "Preferences", "previousMonth": "Previous month", - "profileLogin": "Profile - Login", "profileUser": "Profile - {{username}}", + "register": "Register", "requiredNameError": "Name is required", + "resendVerificationCode": "Resend verification code", "save": "Save", "saveInstruction": "Select your availability and click {{save}}", "share": "Share", @@ -117,13 +115,20 @@ "unlockPoll": "Unlock poll", "unverifiedMessage": "An email has been sent to {{email}} with a link to verify the email address.", "user": "User", + "userAlreadyExists": "A user with that email already exists", + "userNotFound": "A user with that email doesn't exist", + "verificationCodeHelp": "Didn't get the email? Check your spam/junk.", + "verificationCodePlaceholder": "Enter your 6-digit code", + "verificationCodeSent": "A verification code has been sent to {{email}} Change", + "verifyYourEmail": "Verify your email", "weekStartsOn": "Week starts on", "weekView": "Week view", "whatsThis": "What's this?", + "wrongVerificationCode": "Your verification code is incorrect or has expired", "yes": "Yes", "you": "You", "yourDetails": "Your details", "yourName": "Your name…", - "yourProfile": "Your profile", - "yourPolls": "Your polls" + "yourPolls": "Your polls", + "yourProfile": "Your profile" } diff --git a/public/locales/es/app.json b/public/locales/es/app.json index 200b698f6..a878e0322 100644 --- a/public/locales/es/app.json +++ b/public/locales/es/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Café de Carlos", "lockPoll": "Bloquear encuesta", "login": "Iniciar sesión", - "loginCheckInbox": "Por favor, revisa tus correos electrónicos.", - "loginMagicLinkSent": "Se ha enviado un enlace mágico a:", - "loginSendMagicLink": "Enviarme un enlace mágico", - "loginViaMagicLink": "Iniciar sesión a través de un enlace mágico", - "loginViaMagicLinkDescription": "Te enviaremos un correo electrónico con un enlace mágico que puedes usar para iniciar sesión.", - "loginWithValidEmail": "Por favor ingresa un correo electrónico válido", "logout": "Cerrar sesión", "manage": "Gestionar", "menu": "Menú", @@ -97,7 +91,6 @@ "possibleAnswers": "Respuestas posibles", "preferences": "Ajustes", "previousMonth": "Mes anterior", - "profileLogin": "Perfil - Iniciar sesión", "profileUser": "Perfil - {{username}}", "requiredNameError": "El nombre es obligatorio", "save": "Guardar", diff --git a/public/locales/fa/app.json b/public/locales/fa/app.json index 0dbf33255..b2adb0cb4 100644 --- a/public/locales/fa/app.json +++ b/public/locales/fa/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "قهوه‌خونه‌ی سر کوچه", "lockPoll": "قفل نظرسنجی", "login": "ورود", - "loginCheckInbox": "لطفا دریافتی‌های رایانامه‌ی خود را بررسی کنید.", - "loginMagicLinkSent": "یک لینک جادویی به این آدرس ایمیل فرستاده شد:", - "loginSendMagicLink": "برایم لینک جادویی بفرست", - "loginViaMagicLink": "ورود با لینک جادویی", - "loginViaMagicLinkDescription": "ما به شما یک لینک جادویی جهت ورود به سایت خواهیم فرستاد.", - "loginWithValidEmail": "لطفا یک رایانامه معتبر وارد کنید", "logout": "خروج", "manage": "مدیریت", "menu": "فهرست", @@ -93,7 +87,6 @@ "possibleAnswers": "پاسخ‌های ممکن", "preferences": "ترجیحات", "previousMonth": "ماه قبل", - "profileLogin": "نمایه - ورود", "profileUser": "نمایه - {{username}}", "requiredNameError": "نام الزامی است", "save": "ذخیره", diff --git a/public/locales/fi/app.json b/public/locales/fi/app.json index 5d6c6f0bc..22c331e61 100644 --- a/public/locales/fi/app.json +++ b/public/locales/fi/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Maijan kahvila", "lockPoll": "Lukitse kysely", "login": "Kirjaudu sisään", - "loginCheckInbox": "Tarkista sähköpostisi.", - "loginMagicLinkSent": "Taikalinkki on lähetetty osoitteeseen:", - "loginSendMagicLink": "Lähetä taikalinkki", - "loginViaMagicLink": "Kirjaudu sisään taikalinkin avulla", - "loginViaMagicLinkDescription": "Lähetämme sähköpostiisi taikalinkin, jonka avulla voit kirjautua sisään.", - "loginWithValidEmail": "Anna toimiva sähköpostiosoite", "logout": "Kirjaudu ulos", "manage": "Hallinnoi", "menu": "Valikko", @@ -93,7 +87,6 @@ "possibleAnswers": "Vastausvaihtoehdot", "preferences": "Asetukset", "previousMonth": "Edellinen kuukausi", - "profileLogin": "Profiili - Kirjaudu sisään", "profileUser": "Profiili - {{username}}", "requiredNameError": "Nimi vaaditaan", "save": "Tallenna", diff --git a/public/locales/fr/app.json b/public/locales/fr/app.json index 4acde4efc..dba601066 100644 --- a/public/locales/fr/app.json +++ b/public/locales/fr/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Le café de Joe", "lockPoll": "Verrouiller le sondage", "login": "Connexion", - "loginCheckInbox": "Veuillez vérifier votre boîte de réception.", - "loginMagicLinkSent": "Un lien magique a été envoyé à :", - "loginSendMagicLink": "Envoyez-moi un lien magique", - "loginViaMagicLink": "Connexion via le lien magique", - "loginViaMagicLinkDescription": "Nous vous enverrons un courriel contenant un lien magique que vous pourrez utiliser pour vous connecter.", - "loginWithValidEmail": "Veuillez entrer une adresse e-mail valide", "logout": "Déconnexion", "manage": "Gérer", "menu": "Menu", @@ -93,7 +87,6 @@ "possibleAnswers": "Réponses possibles", "preferences": "Préférences", "previousMonth": "Mois précédent", - "profileLogin": "Profil - Connexion", "profileUser": "Profil - {{username}}", "requiredNameError": "Le nom est obligatoire", "save": "Sauvegarder", diff --git a/public/locales/hr/app.json b/public/locales/hr/app.json index 53baa32bb..6793d24b5 100644 --- a/public/locales/hr/app.json +++ b/public/locales/hr/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Ime lokacije", "lockPoll": "Zaključaj anketu", "login": "Prijava", - "loginCheckInbox": "Provjerite svoj inbox.", - "loginMagicLinkSent": "Magična poveznica je poslana:", - "loginSendMagicLink": "Pošalji mi magičnu poveznicu za prijavu", - "loginViaMagicLink": "Prijava korištenjem magične poveznice", - "loginViaMagicLinkDescription": "Poslat ćemo vam poruku e-pošte s čarobnom poveznicom za prijavu.", - "loginWithValidEmail": "Molim vas unesite ispravnu adresu e-pošte", "logout": "Odjava", "manage": "Upravljanje", "menu": "Izbornik", @@ -97,7 +91,6 @@ "possibleAnswers": "Mogući odgovori", "preferences": "Postavke", "previousMonth": "Prethodni mjesec", - "profileLogin": "Profil - prijava", "profileUser": "Profil - {{username}}", "requiredNameError": "Ime je obavezno", "save": "Pohrani", diff --git a/public/locales/hu/app.json b/public/locales/hu/app.json index 48100ca3f..fc3b4c418 100644 --- a/public/locales/hu/app.json +++ b/public/locales/hu/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "János kávézója", "lockPoll": "Szavazás lezárása", "login": "Bejelentkezés", - "loginCheckInbox": "Kérjük, ellenőrizd a beérkező leveleid.", - "loginMagicLinkSent": "Egy varázs linket küldtünk ide:", - "loginSendMagicLink": "Küldj nekem egy varázs linket", - "loginViaMagicLink": "Bejelentkezés varázs linken keresztül", - "loginViaMagicLinkDescription": "Küldünk neked egy emailt egy varázs linkkel, amivel be tudsz jelentkezni.", - "loginWithValidEmail": "Kérjük, adj meg egy érvényes e-mail címet", "logout": "Kijelentkezés", "manage": "Kezelés", "menu": "Menü", @@ -97,7 +91,6 @@ "possibleAnswers": "Lehetséges válaszok", "preferences": "Beállítások", "previousMonth": "Előző hónap", - "profileLogin": "Profil - Bejelentkezés", "profileUser": "Profil - {{username}}", "requiredNameError": "Név megadása kötelező", "save": "Mentés", diff --git a/public/locales/it/app.json b/public/locales/it/app.json index bf94b4c1e..ac9abcadc 100644 --- a/public/locales/it/app.json +++ b/public/locales/it/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Bar di Joe", "lockPoll": "Blocca sondaggio", "login": "Accedi", - "loginCheckInbox": "Verifica la tua posta in arrivo.", - "loginMagicLinkSent": "Un link di accesso è stato inviato a:", - "loginSendMagicLink": "Inviami un link di accesso", - "loginViaMagicLink": "Accedi tramite link di accesso", - "loginViaMagicLinkDescription": "Ti invieremo un email con un link di accesso che puoi usare per effettuare il login.", - "loginWithValidEmail": "Inserisci un indirizzo email valido", "logout": "Esci", "manage": "Gestisci", "menu": "Menu", @@ -93,7 +87,6 @@ "possibleAnswers": "Possibili risposte", "preferences": "Impostazioni", "previousMonth": "Mese precedente", - "profileLogin": "Profilo - Accedi", "profileUser": "Profilo - {{username}}", "requiredNameError": "Il nome è obbligatorio", "save": "Salva", diff --git a/public/locales/ko/app.json b/public/locales/ko/app.json index c56de29b3..0444a6f26 100644 --- a/public/locales/ko/app.json +++ b/public/locales/ko/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Jeo의 카페", "lockPoll": "투표 잠그기", "login": "로그인", - "loginCheckInbox": "메일함을 확인해주세요.", - "loginMagicLinkSent": "매직링크가 다음으로 전송되었습니다.", - "loginSendMagicLink": "매징링크 전송하기", - "loginViaMagicLink": "매직링크로 로그인하기", - "loginViaMagicLinkDescription": "로그인 할 수 있는 매직링크가 담긴 이메일을 전송했습니다.", - "loginWithValidEmail": "올바른 이메일 주소를 입력해주세요.", "logout": "로그아웃", "manage": "관리하기", "menu": "메뉴", @@ -93,7 +87,6 @@ "possibleAnswers": "가능한 답변들", "preferences": "설정", "previousMonth": "지난달", - "profileLogin": "프로필 - 로그인", "profileUser": "프로필 - {{username}}", "requiredNameError": "이름을 입력해주세요.", "save": "저장하기", diff --git a/public/locales/nl/app.json b/public/locales/nl/app.json index b98274516..073e2e00d 100644 --- a/public/locales/nl/app.json +++ b/public/locales/nl/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Joes Koffiebar", "lockPoll": "Poll vergrendelen", "login": "Inloggen", - "loginCheckInbox": "Controleer je inbox.", - "loginMagicLinkSent": "Een magische link is verstuurd naar:", - "loginSendMagicLink": "Stuur me een magische link", - "loginViaMagicLink": "Inloggen via de magische link", - "loginViaMagicLinkDescription": "We sturen je een e-mail met een magische link die je kunt gebruiken om in te loggen.", - "loginWithValidEmail": "Voer een geldig e-mailadres in", "logout": "Uitloggen", "manage": "Beheren", "menu": "Menu", @@ -97,7 +91,6 @@ "possibleAnswers": "Mogelijke antwoorden", "preferences": "Voorkeuren", "previousMonth": "Vorige maand", - "profileLogin": "Profiel - Login", "profileUser": "Profiel - {{username}}", "requiredNameError": "Naam is verplicht", "save": "Opslaan", diff --git a/public/locales/pl/app.json b/public/locales/pl/app.json index b69638031..06bc9a228 100644 --- a/public/locales/pl/app.json +++ b/public/locales/pl/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Sklep z kawą Joe", "lockPoll": "Zablokuj ankietę", "login": "Logowanie", - "loginCheckInbox": "Sprawdź swoją skrzynkę.", - "loginMagicLinkSent": "Magiczny link został wysłany na:", - "loginSendMagicLink": "Wyślij mi magiczny link", - "loginViaMagicLink": "Zaloguj się za pomocą magicznego linku", - "loginViaMagicLinkDescription": "Wyślemy Ci e-mail z magicznym linkiem, którego możesz użyć do logowania.", - "loginWithValidEmail": "Wpisz prawidłowy adres e-mail", "logout": "Wyloguj", "manage": "Zarządzaj", "menu": "Menu", @@ -97,7 +91,6 @@ "possibleAnswers": "Możliwe opcje", "preferences": "Ustawienia", "previousMonth": "Poprzedni miesiąc", - "profileLogin": "Profil - logowanie", "profileUser": "Profil - {{username}}", "requiredNameError": "Imię jest wymagane", "save": "Zapisz", diff --git a/public/locales/pt-BR/app.json b/public/locales/pt-BR/app.json index 2f00c0f8f..ffc6cb079 100644 --- a/public/locales/pt-BR/app.json +++ b/public/locales/pt-BR/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Loja Café do Júlio", "lockPoll": "Bloquear enquete", "login": "Logar", - "loginCheckInbox": "Por gentileza, verifique sua caixa de entrada.", - "loginMagicLinkSent": "Um link mágico foi enviado para:", - "loginSendMagicLink": "Envie-me um link mágico", - "loginViaMagicLink": "Login via link mágico", - "loginViaMagicLinkDescription": "Enviaremos um e-mail com um link mágico que você possa usar para logar.", - "loginWithValidEmail": "Por favor, insira um endereço de e-mail válido", "logout": "Deslogar", "manage": "Gerenciar", "menu": "Menu", @@ -97,7 +91,6 @@ "possibleAnswers": "Possíveis respostas", "preferences": "Preferências", "previousMonth": "Mês anterior", - "profileLogin": "Perfil - Login", "profileUser": "Perfil - {{username}}", "requiredNameError": "Nome é obrigatório", "save": "Salvar", diff --git a/public/locales/pt/app.json b/public/locales/pt/app.json index 632be4ff3..db92e4b36 100644 --- a/public/locales/pt/app.json +++ b/public/locales/pt/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Café do Júlio", "lockPoll": "Bloquear sondagem", "login": "Iniciar sessão", - "loginCheckInbox": "Por favor, consulte a sua caixa de email.", - "loginMagicLinkSent": "Um link mágico foi enviado para:", - "loginSendMagicLink": "Envie-me um link mágico", - "loginViaMagicLink": "Iniciar sessão via link mágico", - "loginViaMagicLinkDescription": "Enviaremos um e-mail com um link mágico que pode usar para iniciar sessão.", - "loginWithValidEmail": "Por favor, introduza um endereço de email válido", "logout": "Terminar sessão", "manage": "Gerir", "menu": "Menu", @@ -93,7 +87,6 @@ "possibleAnswers": "Possíveis respostas", "preferences": "Preferências", "previousMonth": "Mês anterior", - "profileLogin": "Perfil - Iniciar sessão", "profileUser": "Perfil - {{username}}", "requiredNameError": "O nome é obrigatório", "save": "Guardar", diff --git a/public/locales/ru/app.json b/public/locales/ru/app.json index 14298aa59..1bc92a1cd 100644 --- a/public/locales/ru/app.json +++ b/public/locales/ru/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Кофейный магазин Джо", "lockPoll": "Заблокировать опрос", "login": "Войти", - "loginCheckInbox": "Пожалуйста, проверьте вашу электронную почту.", - "loginMagicLinkSent": "Волшебная ссылка отправлена на:", - "loginSendMagicLink": "Отправить мне волшебную ссылку", - "loginViaMagicLink": "Войти по волшебной ссылке", - "loginViaMagicLinkDescription": "Мы вышлем вам письмо с волшебной ссылкой, которую вы можете использовать для входа.", - "loginWithValidEmail": "Пожалуйста, введите корректный email адрес", "logout": "Выйти", "manage": "Настроить", "menu": "Меню", @@ -97,7 +91,6 @@ "possibleAnswers": "Возможные ответы", "preferences": "Настройки", "previousMonth": "Предыдущий месяц", - "profileLogin": "Профиль - Войти", "profileUser": "Профиль - {{username}}", "requiredNameError": "Необходимо указать имя", "save": "Сохранить", diff --git a/public/locales/sk/app.json b/public/locales/sk/app.json index 2675bff94..f426c1b1a 100644 --- a/public/locales/sk/app.json +++ b/public/locales/sk/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Kaviareň u Maca", "lockPoll": "Zamknúť anketu", "login": "Prihlásiť sa", - "loginCheckInbox": "Skontrolujte si prosím svoju e-mailovú schránku.", - "loginMagicLinkSent": "Magický odkaz bol odoslaný na:", - "loginSendMagicLink": "Zaslať magický odkaz", - "loginViaMagicLink": "Prihlásiť sa pomocou magického odkazu", - "loginViaMagicLinkDescription": "Pošleme vám e-mail s magickým odkazom, ktorým sa môžete do aplikácie prihlásiť.", - "loginWithValidEmail": "Zadajte platnú e-mailovú adresu", "logout": "Odhlásiť sa", "manage": "Spravovať", "menu": "Menu", @@ -97,7 +91,6 @@ "possibleAnswers": "Možné odpovede", "preferences": "Nastavenia", "previousMonth": "Predchádzajúci mesiac", - "profileLogin": "Profil - Prihlásenie", "profileUser": "Profil - {{username}}", "requiredNameError": "Požadované je meno", "save": "Uložiť", diff --git a/public/locales/sv/app.json b/public/locales/sv/app.json index b92b407f0..8bea00d23 100644 --- a/public/locales/sv/app.json +++ b/public/locales/sv/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Joe's Café", "lockPoll": "Lås förfrågan", "login": "Logga in", - "loginCheckInbox": "Vänligen kolla din inbox.", - "loginMagicLinkSent": "En magisk länk har skickats till:", - "loginSendMagicLink": "Skicka mig en magisk länk", - "loginViaMagicLink": "Logga in med magisk länk", - "loginViaMagicLinkDescription": "Vi kommer att skicka dig ett mail med en magisk länk som du kan använda för att logga in.", - "loginWithValidEmail": "Skriv in en giltig e-postadress", "logout": "Logga ut", "manage": "Hantera", "menu": "Meny", @@ -97,7 +91,6 @@ "possibleAnswers": "Möjliga svar", "preferences": "Inställningar", "previousMonth": "Föregående månad", - "profileLogin": "Profil - Inloggning", "profileUser": "Profil - {{username}}", "requiredNameError": "Namn är obligatoriskt", "save": "Spara", diff --git a/public/locales/zh/app.json b/public/locales/zh/app.json index 4820d6de8..26b99283d 100644 --- a/public/locales/zh/app.json +++ b/public/locales/zh/app.json @@ -53,12 +53,6 @@ "locationPlaceholder": "Joe 的咖啡店", "lockPoll": "锁定投票", "login": "登录", - "loginCheckInbox": "请检查你的收件箱。", - "loginMagicLinkSent": "魔法链接已发送至:", - "loginSendMagicLink": "发送我的魔术链接", - "loginViaMagicLink": "通过魔术链接登录", - "loginViaMagicLinkDescription": "我们将向你发送一封含有魔术链接的电子邮件,你可以用来登录。", - "loginWithValidEmail": "请输入正确的邮箱地址", "logout": "退出", "manage": "管理", "menu": "菜单", @@ -93,7 +87,6 @@ "possibleAnswers": "可能的选择包括", "preferences": "偏好设置", "previousMonth": "上个月", - "profileLogin": "个人资料 - 登录", "profileUser": "个人资料 - {{username}}", "requiredNameError": "姓名为必填项", "save": "保存", diff --git a/src/components/auth/auth-layout.tsx b/src/components/auth/auth-layout.tsx new file mode 100644 index 000000000..81c5c0a23 --- /dev/null +++ b/src/components/auth/auth-layout.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +import Logo from "~/public/logo.svg"; + +export const AuthLayout = ({ children }: { children?: React.ReactNode }) => { + return ( +
+
+
+
+ +
+
{children}
+
+
+
+ ); +}; diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx new file mode 100644 index 000000000..eb976ea30 --- /dev/null +++ b/src/components/auth/login-form.tsx @@ -0,0 +1,376 @@ +import Link from "next/link"; +import { Trans, useTranslation } from "next-i18next"; +import posthog from "posthog-js"; +import React from "react"; +import { useForm } from "react-hook-form"; + +import { requiredString, validEmail } from "../../utils/form-validation"; +import { trpcNext } from "../../utils/trpc"; +import { Button } from "../button"; +import { TextInput } from "../text-input"; + +const VerifyCode: React.VoidFunctionComponent<{ + email: string; + onSubmit: (code: string) => Promise; + onResend: () => Promise; + onChange: () => void; +}> = ({ onChange, onSubmit, email, onResend }) => { + const { register, handleSubmit, setError, formState } = + useForm<{ code: string }>(); + const { t } = useTranslation("app"); + const [resendStatus, setResendStatus] = + React.useState<"ok" | "busy" | "disabled">("ok"); + + const handleResend = async () => { + setResendStatus("busy"); + try { + await onResend(); + setResendStatus("disabled"); + setTimeout(() => { + setResendStatus("ok"); + }, 1000 * 30); + } catch { + setResendStatus("ok"); + } + }; + + return ( +
+
{ + try { + await onSubmit(code); + } catch { + setError("code", { + type: "not_found", + message: t("wrongVerificationCode"), + }); + } + })} + > +
+
{t("verifyYourEmail")}
+

+ {t("stepSummary", { + current: 2, + total: 2, + })} +

+

+ , + a: ( + { + e.preventDefault(); + onChange(); + }} + /> + ), + }} + /> +

+ + {formState.errors.code?.message ? ( +

+ {formState.errors.code.message} +

+ ) : null} +

+ {t("verificationCodeHelp")} +

+
+
+ + +
+ +
+ ); +}; + +type RegisterFormData = { + name: string; + email: string; +}; + +export const RegisterForm: React.VoidFunctionComponent<{ + onClickLogin?: React.MouseEventHandler; + onRegistered: () => void; + defaultValues?: Partial; +}> = ({ onClickLogin, onRegistered, defaultValues }) => { + const { t } = useTranslation("app"); + const { register, handleSubmit, getValues, setError, formState } = + useForm({ + defaultValues, + }); + const requestRegistration = trpcNext.auth.requestRegistration.useMutation(); + const authenticateRegistration = + trpcNext.auth.authenticateRegistration.useMutation(); + const [token, setToken] = React.useState(); + + if (token) { + return ( + { + const res = await authenticateRegistration.mutateAsync({ + token, + code, + }); + + if (!res.user) { + throw new Error("Failed to authenticate user"); + } + + onRegistered(); + posthog.identify(res.user.id, { + email: res.user.email, + name: res.user.name, + }); + }} + onResend={async () => { + const values = getValues(); + await requestRegistration.mutateAsync({ + email: values.email, + name: values.name, + }); + }} + onChange={() => setToken(undefined)} + email={getValues("email")} + /> + ); + } + + return ( +
{ + const res = await requestRegistration.mutateAsync({ + email: data.email, + name: data.name, + }); + + if (!res.ok) { + switch (res.code) { + case "userAlreadyExists": + setError("email", { + message: t("userAlreadyExists"), + }); + break; + } + } else { + setToken(res.token); + } + })} + > +
{t("createAnAccount")}
+

+ {t("stepSummary", { + current: 1, + total: 2, + })} +

+
+ + + {formState.errors.name?.message ? ( +
+ {formState.errors.name.message} +
+ ) : null} +
+
+ + + {formState.errors.email?.message ? ( +
+ {formState.errors.email.message} +
+ ) : null} +
+ +
+ + ), + }} + /> +
+
+ ); +}; + +export const LoginForm: React.VoidFunctionComponent<{ + onClickRegister?: ( + e: React.MouseEvent, + email: string, + ) => void; + onAuthenticated: () => void; +}> = ({ onAuthenticated, onClickRegister }) => { + const { t } = useTranslation("app"); + const { register, handleSubmit, getValues, formState, setError } = + useForm<{ email: string }>(); + const requestLogin = trpcNext.auth.requestLogin.useMutation(); + const authenticateLogin = trpcNext.auth.authenticateLogin.useMutation(); + + const [token, setToken] = React.useState(); + + if (token) { + return ( + { + const res = await authenticateLogin.mutateAsync({ + code, + token, + }); + + if (!res.user) { + throw new Error("Failed to authenticate user"); + } else { + onAuthenticated(); + posthog.identify(res.user.id, { + email: res.user.email, + name: res.user.name, + }); + } + }} + onResend={async () => { + const values = getValues(); + const res = await requestLogin.mutateAsync({ + email: values.email, + }); + + setToken(res.token); + }} + onChange={() => setToken(undefined)} + email={getValues("email")} + /> + ); + } + + return ( +
{ + const res = await requestLogin.mutateAsync({ + email: data.email, + }); + + if (!res.token) { + setError("email", { + type: "not_found", + message: t("userNotFound"), + }); + } else { + setToken(res.token); + } + })} + > +
{t("login")}
+

+ {t("stepSummary", { + current: 1, + total: 2, + })} +

+
+ + + {formState.errors.email?.message ? ( +
+ {formState.errors.email.message} +
+ ) : null} +
+
+ + { + onClickRegister?.(e, getValues("email")); + }} + > + {t("notRegistered")} + +
+
+ ); +}; diff --git a/src/components/auth/login-modal.tsx b/src/components/auth/login-modal.tsx new file mode 100644 index 000000000..35bbc6946 --- /dev/null +++ b/src/components/auth/login-modal.tsx @@ -0,0 +1,87 @@ +import Link from "next/link"; +import React from "react"; + +import Logo from "~/public/logo.svg"; + +import { useModalContext } from "../modal/modal-provider"; +import { useUser } from "../user-provider"; +import { LoginForm, RegisterForm } from "./login-form"; + +export const LoginModal: React.VoidFunctionComponent<{ + onDone: () => void; +}> = ({ onDone }) => { + const [hasAccount, setHasAccount] = React.useState(false); + const [defaultEmail, setDefaultEmail] = React.useState(""); + + return ( +
+
+ +
+
+ {hasAccount ? ( + { + e.preventDefault(); + setHasAccount(false); + }} + /> + ) : ( + { + e.preventDefault(); + setDefaultEmail(email); + setHasAccount(true); + }} + /> + )} +
+
+ ); +}; + +export const useLoginModal = () => { + const modalContext = useModalContext(); + const { refresh } = useUser(); + + const openLoginModal = () => { + modalContext.render({ + overlayClosable: false, + showClose: true, + content: function Content({ close }) { + return ( + { + refresh(); + close(); + }} + /> + ); + }, + footer: null, + }); + }; + return { openLoginModal }; +}; + +export const LoginLink = ({ + children, + className, +}: React.PropsWithChildren<{ className?: string }>) => { + const { openLoginModal } = useLoginModal(); + return ( + { + e.preventDefault(); + openLoginModal(); + }} + className={className} + > + {children} + + ); +}; diff --git a/src/components/dropdown.tsx b/src/components/dropdown.tsx index 64fd2ad53..875080f1e 100644 --- a/src/components/dropdown.tsx +++ b/src/components/dropdown.tsx @@ -9,7 +9,6 @@ import { import { Menu } from "@headlessui/react"; import clsx from "clsx"; import { motion } from "framer-motion"; -import Link from "next/link"; import * as React from "react"; import { transformOriginByPlacement } from "@/utils/constants"; @@ -83,41 +82,14 @@ const Dropdown: React.VoidFunctionComponent = ({ ); }; -const AnchorLink = React.forwardRef< - HTMLAnchorElement, - { - href?: string; - children?: React.ReactNode; - className?: string; - } ->(function AnchorLink( - { href = "", className, children, ...forwardProps }, - ref, -) { - return ( - - {children} - - ); -}); - export const DropdownItem: React.VoidFunctionComponent<{ icon?: React.ComponentType<{ className?: string }>; label?: React.ReactNode; disabled?: boolean; href?: string; - onClick?: () => void; + onClick?: React.MouseEventHandler; }> = ({ icon: Icon, label, onClick, disabled, href }) => { - const Element = href ? AnchorLink : "button"; + const Element = href ? "a" : "button"; return ( {({ active }) => ( diff --git a/src/components/home/bonus.tsx b/src/components/home/bonus.tsx index e1d01daa9..5d46a4fc3 100644 --- a/src/components/home/bonus.tsx +++ b/src/components/home/bonus.tsx @@ -33,7 +33,12 @@ const Bonus: React.VoidFunctionComponent = () => { t={t} i18nKey={"openSourceDescription"} components={{ - a:
, + a: ( + + ), }} /> diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx deleted file mode 100644 index 828e77bc3..000000000 --- a/src/components/login-form.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import clsx from "clsx"; -import { useRouter } from "next/router"; -import { useTranslation } from "next-i18next"; -import posthog from "posthog-js"; -import * as React from "react"; -import { useForm } from "react-hook-form"; - -import { Button } from "@/components/button"; -import Magic from "@/components/icons/magic.svg"; -import { validEmail } from "@/utils/form-validation"; - -import { trpc } from "../utils/trpc"; - -const LoginForm: React.VoidFunctionComponent = () => { - const { t } = useTranslation("app"); - const { register, formState, handleSubmit, getValues } = - useForm<{ email: string }>(); - - const login = trpc.useMutation(["login"]); - - const router = useRouter(); - return ( -
-
- -
-
-
- {t("loginViaMagicLink")} -
- {!formState.isSubmitSuccessful ? ( -
{ - posthog.capture("login requested", { email }); - await login.mutateAsync({ email, path: router.asPath }); - })} - > -
- {t("loginViaMagicLinkDescription")} -
-
- - {formState.errors.email ? ( -
- {t("loginWithValidEmail")} -
- ) : null} -
-
- -
-
- ) : ( -
-
{t("loginMagicLinkSent")}
-
- {getValues("email")} -
-
{t("loginCheckInbox")}
-
- )} -
-
- ); -}; - -export default LoginForm; diff --git a/src/components/modal/modal-provider.tsx b/src/components/modal/modal-provider.tsx index e5cf61e72..9a79f97fc 100644 --- a/src/components/modal/modal-provider.tsx +++ b/src/components/modal/modal-provider.tsx @@ -28,7 +28,11 @@ export const useModalContext = () => { const ModalProvider: React.VoidFunctionComponent = ({ children, }) => { - const [modals, { push, removeAt, updateAt }] = useList([]); + const counter = React.useRef(0); + + const [modals, { push, removeAt, updateAt }] = useList< + ModalConfig & { id: number } + >([]); const removeModalAt = (index: number) => { updateAt(index, { ...modals[index], visible: false }); @@ -40,14 +44,14 @@ const ModalProvider: React.VoidFunctionComponent = ({ { - push(props); + push({ ...props, id: counter.current++ }); }, }} > {children} {modals.map((props, i) => ( { />
{t("volunteerTranslator")} → diff --git a/src/components/profile.tsx b/src/components/profile.tsx index 622e61adf..3c32e977f 100644 --- a/src/components/profile.tsx +++ b/src/components/profile.tsx @@ -1,5 +1,6 @@ import Head from "next/head"; import Link from "next/link"; +import { useRouter } from "next/router"; import { useTranslation } from "next-i18next"; import * as React from "react"; @@ -10,7 +11,6 @@ import User from "@/components/icons/user.svg"; import { useDayjs } from "../utils/dayjs"; import { trpc } from "../utils/trpc"; import { EmptyState } from "./empty-state"; -import LoginForm from "./login-form"; import { UserDetails } from "./profile/user-details"; import { useUser } from "./user-provider"; @@ -22,16 +22,16 @@ export const Profile: React.VoidFunctionComponent = () => { const { data: userPolls } = trpc.useQuery(["user.getPolls"]); const createdPolls = userPolls?.polls; + const router = useRouter(); + + React.useEffect(() => { + if (user.isGuest) { + router.push("/profile"); + } + }, [router, user.isGuest]); if (user.isGuest) { - return ( -
- - {t("profileLogin")} - - -
- ); + return null; } return ( diff --git a/src/components/standard-layout.tsx b/src/components/standard-layout.tsx index 2f9e40c75..f6e1224d3 100644 --- a/src/components/standard-layout.tsx +++ b/src/components/standard-layout.tsx @@ -10,6 +10,7 @@ import UserCircle from "@/components/icons/user-circle.svg"; import Logo from "~/public/logo.svg"; import { DayjsProvider } from "../utils/dayjs"; +import { LoginLink, useLoginModal } from "./auth/login-modal"; import Dropdown, { DropdownItem, DropdownProps } from "./dropdown"; import Adjustments from "./icons/adjustments.svg"; import Cash from "./icons/cash.svg"; @@ -22,8 +23,6 @@ import Pencil from "./icons/pencil.svg"; import Question from "./icons/question-mark-circle.svg"; import Support from "./icons/support.svg"; import Twitter from "./icons/twitter.svg"; -import LoginForm from "./login-form"; -import { useModal } from "./modal"; import ModalProvider, { useModalContext } from "./modal/modal-provider"; import Popover from "./popover"; import Preferences from "./preferences"; @@ -37,9 +36,7 @@ const HomeLink = () => { ); }; -const MobileNavigation: React.VoidFunctionComponent<{ - openLoginModal: () => void; -}> = ({ openLoginModal }) => { +const MobileNavigation: React.VoidFunctionComponent = () => { const { user } = useUser(); const { t } = useTranslation(["common", "app"]); return ( @@ -52,18 +49,14 @@ const MobileNavigation: React.VoidFunctionComponent<{
{user ? null : ( - + )} {user ? ( = ({ className, }) => { const { t } = useTranslation(["common", "app"]); - console.log("logo", Logo); return (
= ({ ); }; -const UserDropdown: React.VoidFunctionComponent< - DropdownProps & { openLoginModal: () => void } -> = ({ children, openLoginModal, ...forwardProps }) => { +const UserDropdown: React.VoidFunctionComponent = ({ + children, + ...forwardProps +}) => { const { logout, user } = useUser(); const { t } = useTranslation(["common", "app"]); + const { openLoginModal } = useLoginModal(); const modalContext = useModalContext(); if (!user) { return null; @@ -245,12 +239,6 @@ const StandardLayout: React.VoidFunctionComponent<{ }> = ({ children, ...rest }) => { const { user } = useUser(); const { t } = useTranslation(["common", "app"]); - const [loginModal, openLoginModal] = useModal({ - footer: null, - overlayClosable: true, - showClose: true, - content: , - }); return ( @@ -259,8 +247,7 @@ const StandardLayout: React.VoidFunctionComponent<{ className="relative flex min-h-full flex-col bg-gray-50 lg:flex-row" {...rest} > - {loginModal} - +
@@ -298,13 +285,10 @@ const StandardLayout: React.VoidFunctionComponent<{ {user ? null : ( - + )}
@@ -312,7 +296,6 @@ const StandardLayout: React.VoidFunctionComponent<{ void; - logout: () => Promise; + logout: () => void; ownsObject: (obj: { userId: string | null }) => boolean; } | null>(null); @@ -50,27 +50,24 @@ export const IfGuest = (props: { children?: React.ReactNode }) => { export const UserProvider = (props: { children?: React.ReactNode }) => { const { t } = useTranslation("app"); - const { data: user, refetch } = trpcNext.whoami.get.useQuery(); + const queryClient = trpcNext.useContext(); + const { data: user, isFetching } = trpcNext.whoami.get.useQuery(); const logout = trpcNext.whoami.destroy.useMutation({ onSuccess: () => { - posthog.reset(); + queryClient.whoami.invalidate(); }, }); useMount(() => { - if (!process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { - return; - } - - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_API_KEY, { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_API_KEY ?? "fake token", { api_host: process.env.NEXT_PUBLIC_POSTHOG_API_HOST, opt_out_capturing_by_default: false, capture_pageview: false, capture_pageleave: false, autocapture: false, loaded: (posthog) => { - if (process.env.NODE_ENV === "development") { + if (!process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { posthog.opt_out_capturing(); } if (user && posthog.get_distinct_id() !== user.id) { @@ -85,7 +82,9 @@ export const UserProvider = (props: { children?: React.ReactNode }) => { }); }); - const shortName = user + const shortName = isFetching + ? t("loading") + : user ? user.isGuest === false ? user.name : user.id.substring(0, 10) @@ -99,16 +98,17 @@ export const UserProvider = (props: { children?: React.ReactNode }) => { { + return queryClient.whoami.invalidate(); + }, ownsObject: ({ userId }) => { if (userId && user.id === userId) { return true; } return false; }, - logout: async () => { - await logout.mutateAsync(); - refetch(); + logout: () => { + logout.mutate(); }, }} > diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 931ff9a58..376e62cd6 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -1,116 +1,47 @@ import { GetServerSideProps, NextPage } from "next"; import Head from "next/head"; import { useRouter } from "next/router"; -import posthog from "posthog-js"; +import { useTranslation } from "next-i18next"; import React from "react"; -import toast from "react-hot-toast"; -import { useTimeoutFn } from "react-use"; -import FullPageLoader from "@/components/full-page-loader"; -import { - decryptToken, - mergeGuestsIntoUser, - withSessionSsr, -} from "@/utils/auth"; -import { nanoid } from "@/utils/nanoid"; -import { prisma } from "~/prisma/db"; +import { AuthLayout } from "@/components/auth/auth-layout"; +import { LoginForm } from "@/components/auth/login-form"; +import { useUser, withSession } from "@/components/user-provider"; -const Page: NextPage<{ success: boolean; redirectTo: string }> = ({ - success, - redirectTo, -}) => { +import { withSessionSsr } from "../utils/auth"; +import { withPageTranslations } from "../utils/with-page-translations"; + +const Page: NextPage<{ referer: string | null }> = () => { + const { t } = useTranslation("app"); const router = useRouter(); - if (!success) { - toast.error("Login failed! Link is expired or invalid"); - } - - useTimeoutFn(() => { - if (success) { - posthog.capture("login completed"); - } - router.replace(redirectTo); - }, 100); + const { refresh } = useUser(); return ( - <> + - Logging in… + {t("login")} - Logging in… - + { + refresh(); + router.replace("/profile"); + }} + /> + ); }; export const getServerSideProps: GetServerSideProps = withSessionSsr( - async ({ req, query }) => { - const { code } = query; - if (typeof code !== "string") { + async (ctx) => { + if (ctx.req.session.user?.isGuest === false) { return { + redirect: { destination: "/profile" }, props: {}, - redirect: { - destination: "/new", - }, }; } - const { - email, - path = "/new", - guestId, - } = await decryptToken<{ - email?: string; - path?: string; - guestId?: string; - }>(code); - - if (!email) { - return { - props: { - success: false, - redirectTo: path, - }, - }; - } - - const user = await prisma.user.upsert({ - where: { email }, - update: {}, - create: { - id: await nanoid(), - name: email.substring(0, email.indexOf("@")), - email, - }, - }); - - const guestIds: string[] = []; - - // guest id from existing sessions - if (req.session.user?.isGuest) { - guestIds.push(req.session.user.id); - } - // guest id from token - if (guestId && guestId !== req.session.user?.id) { - guestIds.push(guestId); - } - - if (guestIds.length > 0) { - await mergeGuestsIntoUser(user.id, guestIds); - } - - req.session.user = { - isGuest: false, - id: user.id, - }; - - await req.session.save(); - - return { - props: { - success: true, - redirectTo: path, - }, - }; + return await withPageTranslations(["common", "app"])(ctx); }, ); -export default Page; +export default withSession(Page); diff --git a/src/pages/logout.tsx b/src/pages/logout.tsx new file mode 100644 index 000000000..f8279e4d0 --- /dev/null +++ b/src/pages/logout.tsx @@ -0,0 +1,19 @@ +import { NextPage } from "next"; + +import { withSessionSsr } from "../utils/auth"; + +const Page: NextPage = () => { + return null; +}; + +export const getServerSideProps = withSessionSsr(async (ctx) => { + ctx.req.session.destroy(); + return { + redirect: { + destination: ctx.req.headers.referer ?? "/login", + permanent: false, + }, + }; +}); + +export default Page; diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index 939ffe34a..c5fdbf61e 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -15,8 +15,16 @@ const Page: NextPage = () => { ); }; -export const getServerSideProps = withSessionSsr( - withPageTranslations(["common", "app"]), -); +export const getServerSideProps = withSessionSsr(async (ctx) => { + if (ctx.req.session.user.isGuest !== false) { + return { + redirect: { + destination: "/login", + }, + props: {}, + }; + } + return withPageTranslations(["common", "app"])(ctx); +}); export default withSession(Page); diff --git a/src/pages/register.tsx b/src/pages/register.tsx new file mode 100644 index 000000000..8df211406 --- /dev/null +++ b/src/pages/register.tsx @@ -0,0 +1,33 @@ +import { NextPage } from "next"; +import Head from "next/head"; +import { useRouter } from "next/router"; +import { useTranslation } from "next-i18next"; + +import { AuthLayout } from "../components/auth/auth-layout"; +import { RegisterForm } from "../components/auth/login-form"; +import { withSession } from "../components/user-provider"; +import { withSessionSsr } from "../utils/auth"; +import { withPageTranslations } from "../utils/with-page-translations"; + +const Page: NextPage = () => { + const { t } = useTranslation("app"); + const router = useRouter(); + return ( + + + {t("register")} + + { + router.replace("/profile"); + }} + /> + + ); +}; + +export const getServerSideProps = withSessionSsr( + withPageTranslations(["common", "app"]), +); + +export default withSession(Page); diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index 1ee08b9ee..070974e59 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -1,21 +1,21 @@ import { createRouter } from "../createRouter"; import { mergeRouters, router } from "../trpc"; +import { auth } from "./auth"; import { login } from "./login"; import { polls } from "./polls"; -import { session } from "./session"; import { user } from "./user"; import { whoami } from "./whoami"; const legacyRouter = createRouter() .merge("user.", user) .merge(login) - .merge("polls.", polls) - .merge("session.", session); + .merge("polls.", polls); export const appRouter = mergeRouters( legacyRouter.interop(), router({ whoami, + auth, }), ); diff --git a/src/server/routers/auth.ts b/src/server/routers/auth.ts new file mode 100644 index 000000000..0e329e0a3 --- /dev/null +++ b/src/server/routers/auth.ts @@ -0,0 +1,177 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { prisma } from "~/prisma/db"; +import emailTemplate from "~/templates/email-verification"; + +import { absoluteUrl } from "../../utils/absolute-url"; +import { sendEmailTemplate } from "../../utils/api-utils"; +import { + createToken, + decryptToken, + LoginTokenPayload, + mergeGuestsIntoUser, + RegistrationTokenPayload, +} from "../../utils/auth"; +import { generateOtp } from "../../utils/nanoid"; +import { publicProcedure, router } from "../trpc"; + +const sendVerificationEmail = async ( + email: string, + name: string, + code: string, +) => { + await sendEmailTemplate({ + to: email, + subject: `Your 6-digit code is: ${code}`, + templateString: emailTemplate, + templateVars: { + homePageUrl: absoluteUrl(), + code, + name, + }, + }); +}; + +export const auth = router({ + requestRegistration: publicProcedure + .input( + z.object({ + name: z.string(), + email: z.string(), + }), + ) + .mutation(async ({ input }): Promise< + { ok: true; token: string } | { ok: false; code: "userAlreadyExists" } + > => { + const user = await prisma.user.findUnique({ + select: { + id: true, + }, + where: { + email: input.email, + }, + }); + + if (user) { + return { ok: false, code: "userAlreadyExists" }; + } + + const code = await generateOtp(); + + const token = await createToken({ + name: input.name, + email: input.email, + code, + }); + + await sendVerificationEmail(input.email, input.name, code); + + return { ok: true, token }; + }), + authenticateRegistration: publicProcedure + .input( + z.object({ + token: z.string(), + code: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const { name, email, code } = + await decryptToken(input.token); + + if (input.code !== code) { + return { ok: false }; + } + + const user = await prisma.user.create({ + select: { id: true, name: true, email: true }, + data: { + name, + email, + }, + }); + + if (ctx.session.user?.isGuest) { + await mergeGuestsIntoUser(user.id, [ctx.session.user.id]); + } + + ctx.session.user = { + isGuest: false, + id: user.id, + }; + + await ctx.session.save(); + + return { ok: true, user }; + }), + requestLogin: publicProcedure + .input( + z.object({ + email: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ token?: string }> => { + const user = await prisma.user.findUnique({ + where: { + email: input.email, + }, + }); + + if (!user) { + return { token: undefined }; + } + + const code = await generateOtp(); + + const token = await createToken({ + userId: user.id, + code, + }); + + await sendVerificationEmail(input.email, user.name, code); + + return { token }; + }), + authenticateLogin: publicProcedure + .input( + z.object({ + token: z.string(), + code: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const { userId, code } = await decryptToken( + input.token, + ); + + if (input.code !== code) { + return { user: null }; + } + + const user = await prisma.user.findUnique({ + select: { id: true, name: true, email: true }, + where: { id: userId }, + }); + + if (!user) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The user doesn't exist anymore", + }); + } + + if (ctx.session.user?.isGuest) { + await mergeGuestsIntoUser(user.id, [ctx.session.user.id]); + } + + ctx.session.user = { + isGuest: false, + id: user.id, + }; + + await ctx.session.save(); + + return { user }; + }), +}); diff --git a/src/server/routers/session.ts b/src/server/routers/session.ts deleted file mode 100644 index ce428118e..000000000 --- a/src/server/routers/session.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { prisma } from "~/prisma/db"; - -import { createGuestUser } from "../../utils/auth"; -import { createRouter } from "../createRouter"; - -export const session = createRouter() - .query("get", { - async resolve({ - ctx, - }): Promise< - | { isGuest: true; id: string } - | { isGuest: false; id: string; name: string; email: string } - > { - if (ctx.session.user.isGuest) { - return { isGuest: true, id: ctx.session.user.id }; - } - - const user = await prisma.user.findUnique({ - where: { id: ctx.session.user.id }, - }); - - if (!user) { - ctx.session.user = await createGuestUser(); - await ctx.session.save(); - return { isGuest: true, id: ctx.session.user.id }; - } - - return { - isGuest: false, - id: user.id, - email: user.email, - name: user.name, - }; - }, - }) - .mutation("destroy", { - async resolve({ ctx }) { - ctx.session.destroy(); - }, - }); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 846a0eb83..b04940114 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -21,6 +21,17 @@ const sessionOptions: IronSessionOptions = { ttl: 0, // basically forever }; +export type RegistrationTokenPayload = { + name: string; + email: string; + code: string; +}; + +export type LoginTokenPayload = { + userId: string; + code: string; +}; + export type RegisteredUserSession = { isGuest: false; id: string; diff --git a/src/utils/nanoid.ts b/src/utils/nanoid.ts index 742ea8668..11691adea 100644 --- a/src/utils/nanoid.ts +++ b/src/utils/nanoid.ts @@ -9,3 +9,5 @@ export const randomid = customAlphabet( "0123456789abcdefghijklmnopqrstuvwxyz", 12, ); + +export const generateOtp = customAlphabet("0123456789", 6); diff --git a/src/utils/send-email.ts b/src/utils/send-email.ts index 560e64ba6..f3b9e5087 100644 --- a/src/utils/send-email.ts +++ b/src/utils/send-email.ts @@ -8,17 +8,23 @@ interface SendEmailParameters { let transport: nodemailer.Transporter; +const env = process.env["NODE" + "_ENV"] || "development"; + const getTransport = async () => { if (!transport) { - transport = nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port: parseInt(process.env.SMTP_PORT), - secure: process.env.SMTP_SECURE === "true", - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PWD, - }, - }); + if (env === "test") { + transport = nodemailer.createTransport({ port: 4025 }); + } else { + transport = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT), + secure: process.env.SMTP_SECURE === "true", + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PWD, + }, + }); + } } return transport; }; diff --git a/style.css b/style.css index eedd98774..8ad76a662 100644 --- a/style.css +++ b/style.css @@ -29,9 +29,7 @@ input { @apply outline-none; } - a { - @apply rounded-sm font-medium text-primary-500 outline-none hover:text-primary-500 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1; - } + label { @apply mb-1 block text-sm text-slate-800; } @@ -45,6 +43,9 @@ } @layer components { + .text-link { + @apply rounded-sm font-medium text-primary-500 outline-none hover:text-primary-500 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1; + } .formField { @apply mb-4; } diff --git a/templates/email-verification.ts b/templates/email-verification.ts new file mode 100644 index 000000000..c1cc0a35d --- /dev/null +++ b/templates/email-verification.ts @@ -0,0 +1,314 @@ +const template = ` + + + + + + + + + Please verify your email address + + + +
+ Use the 6-digit code provided to complete the verification process.͏ + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ͏ ‌  ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ + ͏ ͏ ͏ ͏ ‌  ͏ ͏ ͏ ͏ + ͏ +
+
+ + + + +
+ + + + + + + + + + + +
+
+ +`; + +export default template; diff --git a/templates/login.html b/templates/login.html deleted file mode 100644 index 58bfe1766..000000000 --- a/templates/login.html +++ /dev/null @@ -1,148 +0,0 @@ - - - - - - - - - - Login with your email - - - -
- Please click the link below to verify your email address.͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌ -  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌ -  ͏ ͏ ͏ ͏ ͏ -
-
- - - - -
- - - - - - - - -
-
- - diff --git a/templates/new-poll-verified.ts b/templates/new-poll-verified.ts index fcb41ffcb..e7f71bebd 100644 --- a/templates/new-poll-verified.ts +++ b/templates/new-poll-verified.ts @@ -99,7 +99,7 @@ const template = `

- + " class="hover-bg-indigo-400" style="display: inline-block; border-radius: 4px; background-color: #6366f1; padding-top: 16px; padding-bottom: 16px; padding-left: 24px; padding-right: 24px; text-align: center; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none;"> Go to poll → diff --git a/templates/new-poll.ts b/templates/new-poll.ts index 8e25da159..cd0e9f91f 100644 --- a/templates/new-poll.ts +++ b/templates/new-poll.ts @@ -100,7 +100,7 @@ const template = `

- + Verify your email → @@ -110,7 +110,7 @@ const template = ` future 😉

- + " style="display: inline-block; background-color: #eef2ff; padding: 8px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: #6366f1; text-decoration: none;"> <%= it.pollUrl %>

diff --git a/tests/authentication.spec.ts b/tests/authentication.spec.ts new file mode 100644 index 000000000..e20650b77 --- /dev/null +++ b/tests/authentication.spec.ts @@ -0,0 +1,110 @@ +import { expect, test } from "@playwright/test"; +import { load } from "cheerio"; +import smtpTester from "smtp-tester"; + +import { prisma } from "~/prisma/db"; + +const testUserEmail = "test@example.com"; + +test.describe.serial(() => { + let mailServer: smtpTester.SmtpTester; + + test.beforeAll(() => { + mailServer = smtpTester.init(4025); + }); + + test.afterAll(async () => { + try { + await prisma.user.delete({ + where: { + email: testUserEmail, + }, + }); + } catch { + // User doesn't exist + } + mailServer.stop(); + }); + + /** + * Get the 6-digit code from the email + * @returns 6-digit code + */ + const getCode = async () => { + const { email } = await mailServer.captureOne(testUserEmail, { + wait: 5000, + }); + + const $ = load(email.html); + + return $("#code").text().trim(); + }; + + test("shows that user doesn't exist yet", async ({ page }) => { + await page.goto("/login"); + + // your login page test logic + await page.type("input[name=email]", "test@example.com"); + + await page.click("text=Continue"); + + // Make sure the user doesn't exist yet and that logging in is not possible + await expect( + page.getByText("A user with that email doesn't exist"), + ).toBeVisible(); + }); + + test("user registration", async ({ page }) => { + await page.goto("/register"); + + await page.getByText("Create an account").waitFor(); + + await page.getByPlaceholder("Jessie Smith").type("Test User"); + await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail); + + await page.click("text=Continue"); + + const codeInput = page.getByPlaceholder("Enter your 6-digit code"); + + codeInput.waitFor({ state: "visible" }); + + const code = await getCode(); + + await codeInput.type(code); + + await page.getByText("Continue").click(); + + await expect(page.getByText("Your details")).toBeVisible(); + }); + + test("can't register with the same email", async ({ page }) => { + await page.goto("/register"); + + await page.getByText("Create an account").waitFor(); + + await page.getByPlaceholder("Jessie Smith").type("Test User"); + await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail); + + await page.click("text=Continue"); + + await expect( + page.getByText("A user with that email already exists"), + ).toBeVisible(); + }); + + test("user login", async ({ page }) => { + await page.goto("/login"); + + await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail); + + await page.getByText("Continue").click(); + + const code = await getCode(); + + await page.getByPlaceholder("Enter your 6-digit code").type(code); + + await page.getByText("Continue").click(); + + await expect(page.getByText("Your details")).toBeVisible(); + }); +}); diff --git a/tests/create-delete-poll.spec.ts b/tests/create-delete-poll.spec.ts index de04465a4..c40de6951 100644 --- a/tests/create-delete-poll.spec.ts +++ b/tests/create-delete-poll.spec.ts @@ -1,55 +1,87 @@ import { expect, test } from "@playwright/test"; +import smtpTester from "smtp-tester"; -test("should be able to create a new poll and delete it", async ({ page }) => { - await page.goto("/new"); - await page.type('[placeholder="Monthly Meetup"]', "Monthly Meetup"); - // click on label to focus on input - await page.click('text="Location"'); - await page.keyboard.type("Joe's Coffee Shop"); +test.describe.serial(() => { + let mailServer: smtpTester.SmtpTester; - await page.click('text="Description"'); + let pollUrl: string; - await page.keyboard.type("This is a test description"); + test.beforeAll(async () => { + mailServer = smtpTester.init(4025); + }); - await page.click('text="Continue"'); + test.afterAll(async () => { + mailServer.stop(); + }); - await page.click('[title="Next month"]'); + test("create a new poll", async ({ page }) => { + await page.goto("/new"); + await page.type('[placeholder="Monthly Meetup"]', "Monthly Meetup"); + // click on label to focus on input + await page.click('text="Location"'); + await page.keyboard.type("Joe's Coffee Shop"); - // Select a few days - await page.click("text=/^5$/"); - await page.click("text=/^7$/"); - await page.click("text=/^10$/"); - await page.click("text=/^15$/"); + await page.click('text="Description"'); - await page.click('text="Continue"'); + await page.keyboard.type("This is a test description"); - await page.type('[placeholder="Jessie Smith"]', "John"); - await page.type( - '[placeholder="jessie.smith@email.com"]', - "john.doe@email.com", - ); + await page.click('text="Continue"'); - await page.click('text="Create poll"'); + await page.click('[title="Next month"]'); - await expect(page.locator("data-testid=poll-title")).toHaveText( - "Monthly Meetup", - ); + // Select a few days + await page.click("text=/^5$/"); + await page.click("text=/^7$/"); + await page.click("text=/^10$/"); + await page.click("text=/^15$/"); - // let's delete the poll we just created - await page.click("text=Manage"); - await page.click("text=Delete poll"); + await page.click('text="Continue"'); - const deletePollForm = page.locator("data-testid=delete-poll-form"); + await page.type('[placeholder="Jessie Smith"]', "John"); + await page.type( + '[placeholder="jessie.smith@email.com"]', + "john.doe@email.com", + ); - // button should be disabled - await expect(deletePollForm.locator("text=Delete poll")).toBeDisabled(); + await page.click('text="Create poll"'); - // enter confirmation text - await page.type("[placeholder=delete-me]", "delete-me"); + const { email } = await mailServer.captureOne("john.doe@email.com", { + wait: 5000, + }); - // button should now be enabled - await deletePollForm.locator("text=Delete poll").click(); + expect(email.headers.subject).toBe( + "Rallly: Monthly Meetup - Verify your email address", + ); - // expect delete message to appear - await expect(page.locator("text=Deleted poll")).toBeVisible(); + const title = page.getByTestId("poll-title"); + + await title.waitFor(); + + pollUrl = page.url(); + + await expect(title).toHaveText("Monthly Meetup"); + }); + + // delete the poll we just created + test("delete existing poll", async ({ page }) => { + await page.goto(pollUrl); + const manageButton = page.getByText("Manage"); + await manageButton.waitFor(); + await manageButton.click(); + await page.click("text=Delete poll"); + + const deletePollForm = page.locator("data-testid=delete-poll-form"); + + // button should be disabled + await expect(deletePollForm.locator("text=Delete poll")).toBeDisabled(); + + // enter confirmation text + await page.type("[placeholder=delete-me]", "delete-me"); + + // button should now be enabled + await deletePollForm.locator("text=Delete poll").click(); + + // expect delete message to appear + await expect(page.locator("text=Deleted poll")).toBeVisible(); + }); }); diff --git a/tests/edit-options.spec.ts b/tests/edit-options.spec.ts index 09a48f585..343a638cb 100644 --- a/tests/edit-options.spec.ts +++ b/tests/edit-options.spec.ts @@ -1,18 +1,20 @@ import { expect, test } from "@playwright/test"; -test("should show warning when deleting options with votes in them", async ({ - page, -}) => { - await page.goto("/demo"); +test.describe("Edit options", () => { + test("should show warning when deleting options with votes in them", async ({ + page, + }) => { + await page.goto("/demo"); - await expect(page.locator('text="Lunch Meeting"')).toBeVisible(); + await expect(page.locator('text="Lunch Meeting"')).toBeVisible(); - await page.click("text='Manage'"); - await page.click("text='Edit options'"); - await page.click("[data-testid='specify-times-switch']"); - await page.click("text='12:00 PM'"); - await page.click("text='1:00 PM'"); - await page.locator("div[role='dialog']").locator("text='Save'").click(); - await expect(page.locator('text="Are you sure?"')).toBeVisible(); - await page.click("text='Delete'"); + await page.click("text='Manage'"); + await page.click("text='Edit options'"); + await page.click("[data-testid='specify-times-switch']"); + await page.click("text='12:00 PM'"); + await page.click("text='1:00 PM'"); + await page.locator("div[role='dialog']").locator("text='Save'").click(); + await expect(page.locator('text="Are you sure?"')).toBeVisible(); + await page.click("text='Delete'"); + }); }); diff --git a/yarn.lock b/yarn.lock index 7cbff6b27..4ce491461 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1296,13 +1296,13 @@ "@nodelib/fs.scandir" "2.1.4" fastq "^1.6.0" -"@playwright/test@^1.28.1": - version "1.29.2" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.29.2.tgz#c48184721d0f0b7627a886e2ec42f1efb2be339d" - integrity sha512-+3/GPwOgcoF0xLz/opTnahel1/y42PdcgZ4hs+BZGIUjtmEFSXGg+nFoaH3NSmuc7a6GSFwXDJ5L7VXpqzigNg== +"@playwright/test@^1.30.0": + version "1.30.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.30.0.tgz#8c0c4930ff2c7be7b3ec3fd434b2a3b4465ed7cb" + integrity sha512-SVxkQw1xvn/Wk/EvBnqWIq6NLo1AppwbYOjNLmyU0R1RoQ3rLEBtmjTnElcnz8VEtn11fptj1ECxK0tgURhajw== dependencies: "@types/node" "*" - playwright-core "1.29.2" + playwright-core "1.30.0" "@polka/url@^1.0.0-next.20": version "1.0.0-next.21" @@ -1573,6 +1573,14 @@ resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.1.4.tgz#0c8b74c50f29ee44f423f7416829c0bf8bb5eb27" integrity sha512-LwzQKA4vzIct1zNZzBmRKI9QuNpLgTQMEjsQLf3BXuGYb3QPTP4Yjf6mkdX+X1mYttZ808QpOwAzZjv28kq7DA== +"@selderee/plugin-htmlparser2@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.10.0.tgz#8a304d18df907e086f3cfc71ea0ced52d6524430" + integrity sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA== + dependencies: + domhandler "^5.0.3" + selderee "^0.10.0" + "@sentry/browser@7.33.0": version "7.33.0" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.33.0.tgz#0360fd323afda1066734b6d175e55ca1c3264898" @@ -2456,6 +2464,11 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base32.js@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.1.0.tgz#b582dec693c2f11e893cf064ee6ac5b6131a2202" + integrity sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz" @@ -2560,6 +2573,31 @@ chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +cheerio-select@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" + integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== + dependencies: + boolbase "^1.0.0" + css-select "^5.1.0" + css-what "^6.1.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + +cheerio@^1.0.0-rc.12: + version "1.0.0-rc.12" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" + integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== + dependencies: + cheerio-select "^2.1.0" + dom-serializer "^2.0.0" + domhandler "^5.0.3" + domutils "^3.0.1" + htmlparser2 "^8.0.1" + parse5 "^7.0.0" + parse5-htmlparser2-tree-adapter "^7.0.0" + chokidar@^3.5.3: version "3.5.3" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz" @@ -2747,6 +2785,17 @@ css-select@^4.1.3: domutils "^2.8.0" nth-check "^2.0.1" +css-select@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" + integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + css-tree@^1.1.2, css-tree@^1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz" @@ -2760,6 +2809,11 @@ css-what@^5.1.0: resolved "https://registry.npmjs.org/css-what/-/css-what-5.1.0.tgz" integrity sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw== +css-what@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz" @@ -2937,11 +2991,25 @@ dom-serializer@^1.0.1: domhandler "^4.2.0" entities "^2.0.0" +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + domelementtype@^2.0.1, domelementtype@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz" integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + domhandler@^4.2.0, domhandler@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/domhandler/-/domhandler-4.3.0.tgz" @@ -2949,6 +3017,13 @@ domhandler@^4.2.0, domhandler@^4.3.0: dependencies: domelementtype "^2.2.0" +domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + domutils@^2.8.0: version "2.8.0" resolved "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz" @@ -2958,6 +3033,15 @@ domutils@^2.8.0: domelementtype "^2.2.0" domhandler "^4.2.0" +domutils@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.0.1.tgz#696b3875238338cb186b6c0612bd4901c89a4f1c" + integrity sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.1" + duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" @@ -2978,6 +3062,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +encoding-japanese@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encoding-japanese/-/encoding-japanese-2.0.0.tgz#fa0226e5469e7b5b69a04fea7d5481bd1fa56936" + integrity sha512-++P0RhebUC8MJAwJOsT93dT+5oc5oPImp1HubZpAuCZ5kTLnhuuBhKHj2jJeO/Gj93idPBWmIuQ9QWMe5rX3pQ== + enquirer@^2.3.5: version "2.3.6" resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz" @@ -2995,6 +3084,11 @@ entities@^3.0.1: resolved "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz" integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q== +entities@^4.2.0, entities@^4.3.0, entities@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174" + integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA== + error-ex@^1.2.0, error-ex@^1.3.1: version "1.3.2" resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz" @@ -3790,6 +3884,11 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + hey-listen@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" @@ -3814,6 +3913,27 @@ html-parse-stringify@^3.0.1: dependencies: void-elements "3.1.0" +html-to-text@9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-9.0.3.tgz#331368f32fcb270c59dbd3a7fdb32813d2a490bc" + integrity sha512-hxDF1kVCF2uw4VUJ3vr2doc91pXf2D5ngKcNviSitNkhP9OMOaJkDrFIFL6RMvko7NisWTEiqGpQ9LAxcVok1w== + dependencies: + "@selderee/plugin-htmlparser2" "^0.10.0" + deepmerge "^4.2.2" + dom-serializer "^2.0.0" + htmlparser2 "^8.0.1" + selderee "^0.10.0" + +htmlparser2@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.1.tgz#abaa985474fcefe269bc761a779b544d7196d010" + integrity sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + domutils "^3.0.1" + entities "^4.3.0" + https-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" @@ -3855,6 +3975,13 @@ i18next@^22.0.4: dependencies: "@babel/runtime" "^7.17.2" +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ignore@^4.0.6: version "4.0.6" resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz" @@ -3924,6 +4051,11 @@ invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +ipv6-normalize@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz#1b3258290d365fa83239e89907dde4592e7620a8" + integrity sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA== + iron-session@^6.1.3: version "6.1.3" resolved "https://registry.yarnpkg.com/iron-session/-/iron-session-6.1.3.tgz#c900102560e7d19541a9e6b8bbabc5436b01a230" @@ -4238,6 +4370,11 @@ language-tags@^1.0.5: dependencies: language-subtag-registry "~0.3.2" +leac@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912" + integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg== + levn@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" @@ -4246,6 +4383,26 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +libbase64@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/libbase64/-/libbase64-1.2.1.tgz#fb93bf4cb6d730f29b92155b6408d1bd2176a8c8" + integrity sha512-l+nePcPbIG1fNlqMzrh68MLkX/gTxk/+vdvAb388Ssi7UuUN31MI44w4Yf33mM3Cm4xDfw48mdf3rkdHszLNew== + +libmime@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/libmime/-/libmime-5.2.0.tgz#c4ed5cbd2d9fdd27534543a68bb8d17c658d51d8" + integrity sha512-X2U5Wx0YmK0rXFbk67ASMeqYIkZ6E5vY7pNWRKtnNzqjvdYYG8xtPDpCnuUEnPU9vlgNev+JoSrcaKSUaNvfsw== + dependencies: + encoding-japanese "2.0.0" + iconv-lite "0.6.3" + libbase64 "1.2.1" + libqp "2.0.1" + +libqp@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/libqp/-/libqp-2.0.1.tgz#b8fed76cc1ea6c9ceff8888169e4e0de70cd5cf2" + integrity sha512-Ka0eC5LkF3IPNQHJmYBWljJsw0UvM6j+QdKRbWyCdTmYwvIDE6a7bCm0UkTAL/K+3KXK5qXT/ClcInU01OpdLg== + lie@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" @@ -4263,6 +4420,13 @@ lines-and-columns@^1.1.6: resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= +linkify-it@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec" + integrity sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw== + dependencies: + uc.micro "^1.0.1" + linkify-it@^2.0.3: version "2.2.0" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf" @@ -4366,6 +4530,30 @@ magic-string@^0.27.0: dependencies: "@jridgewell/sourcemap-codec" "^1.4.13" +mailparser@^3.5.0: + version "3.6.3" + resolved "https://registry.yarnpkg.com/mailparser/-/mailparser-3.6.3.tgz#7edcfd9af7931e8a724e97880756477a9ea80f88" + integrity sha512-Yi6poKSsZsmjEcUexv3H4w4+TIeyN9u3+TCdC43VK7fe4rUOGDJ3wL4kMhNLiTOScCA1Rpzldv1hcf6g1MLtZQ== + dependencies: + encoding-japanese "2.0.0" + he "1.2.0" + html-to-text "9.0.3" + iconv-lite "0.6.3" + libmime "5.2.0" + linkify-it "4.0.1" + mailsplit "5.4.0" + nodemailer "6.8.0" + tlds "1.236.0" + +mailsplit@5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/mailsplit/-/mailsplit-5.4.0.tgz#9f4692fadd9013e9ce632147d996931d2abac6ba" + integrity sha512-wnYxX5D5qymGIPYLwnp6h8n1+6P6vz/MJn5AzGjZ8pwICWssL+CCQjWBIToOVHASmATot4ktvlLo6CyLfOXWYA== + dependencies: + libbase64 "1.2.1" + libmime "5.2.0" + libqp "2.0.1" + mdn-data@2.0.14: version "2.0.14" resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz" @@ -4556,6 +4744,16 @@ node-releases@^2.0.1: resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz" integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA== +nodemailer@6.7.3: + version "6.7.3" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.3.tgz#b73f9a81b9c8fa8acb4ea14b608f5e725ea8e018" + integrity sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g== + +nodemailer@6.8.0: + version "6.8.0" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.8.0.tgz#804bcc5256ee5523bc914506ee59f8de8f0b1cd5" + integrity sha512-EjYvSmHzekz6VNkNd12aUqAco+bOkRe3Of5jVhltqKhEsjw/y0PYPJfp83+s9Wzh1dspYAkUW/YNQ350NATbSQ== + nodemailer@^6.7.2: version "6.7.2" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.2.tgz#44b2ad5f7ed71b7067f7a21c4fedabaec62b85e0" @@ -4780,6 +4978,29 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" +parse5-htmlparser2-tree-adapter@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1" + integrity sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g== + dependencies: + domhandler "^5.0.2" + parse5 "^7.0.0" + +parse5@^7.0.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + +parseley@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.11.0.tgz#1ff817c829a02fcc214c9cc0d96b126d772ee814" + integrity sha512-VfcwXlBWgTF+unPcr7yu3HSSA6QUdDaDnrHcytVfj5Z8azAyKBDrYnSIfeSxlrEayndNcLmrXzg+Vxbo6DWRXQ== + dependencies: + leac "^0.6.0" + peberminta "^0.8.0" + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz" @@ -4817,6 +5038,11 @@ path-type@^4.0.0: resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +peberminta@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.8.0.tgz#acf7b105f3d13c8ac28cad81f2f5fe4698507590" + integrity sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz" @@ -4844,10 +5070,10 @@ pkg-dir@^2.0.0: dependencies: find-up "^2.1.0" -playwright-core@1.29.2: - version "1.29.2" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.29.2.tgz#2e8347e7e8522409f22b244e600e703b64022406" - integrity sha512-94QXm4PMgFoHAhlCuoWyaBYKb92yOcGVHdQLoxQ7Wjlc7Flg4aC/jbFW7xMR52OfXMVkWicue4WXE7QEegbIRA== +playwright-core@1.30.0: + version "1.30.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.30.0.tgz#de987cea2e86669e3b85732d230c277771873285" + integrity sha512-7AnRmTCf+GVYhHbLJsGUtskWTE33SwMZkybJ0v6rqR1boxq2x36U7p1vDRV7HO2IwTZgmycracLxPEJI49wu4g== popmotion@11.0.3: version "11.0.3" @@ -5375,6 +5601,11 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + scheduler@^0.23.0: version "0.23.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" @@ -5387,6 +5618,13 @@ screenfull@^5.1.0: resolved "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz" integrity sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA== +selderee@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.10.0.tgz#ec83d6044d9026668dc9bd2561acfde99a4e3a1c" + integrity sha512-DEL/RW/f4qLw/NrVg97xKaEBC8IpzIG2fvxnzCp3Z4yk4jQ3MXom+Imav9wApjxX2dfS3eW7x0DXafJr85i39A== + dependencies: + parseley "^0.11.0" + "semver@2 || 3 || 4 || 5": version "5.7.1" resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz" @@ -5473,6 +5711,23 @@ smoothscroll-polyfill@^0.4.4: resolved "https://registry.yarnpkg.com/smoothscroll-polyfill/-/smoothscroll-polyfill-0.4.4.tgz#3a259131dc6930e6ca80003e1cb03b603b69abf8" integrity sha512-TK5ZA9U5RqCwMpfoMq/l1mrH0JAR7y7KRvOBx0n2869aLxch+gT9GhN3yUfjiw+d/DiF1mKo14+hd62JyMmoBg== +smtp-server@^3.11.0: + version "3.11.0" + resolved "https://registry.yarnpkg.com/smtp-server/-/smtp-server-3.11.0.tgz#8820c191124fab37a8f16c8325a7f1fd38092c4f" + integrity sha512-j/W6mEKeMNKuiM9oCAAjm87agPEN1O3IU4cFLT4ZOCyyq3UXN7HiIXF+q7izxJcYSar15B/JaSxcijoPCR8Tag== + dependencies: + base32.js "0.1.0" + ipv6-normalize "1.0.1" + nodemailer "6.7.3" + +smtp-tester@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/smtp-tester/-/smtp-tester-2.0.1.tgz#f971a1aaca964a9c9a955dc796e77dd851e1e315" + integrity sha512-mJicx4trPmlS2PY/ELG4LIKi8JdOGxnvm0/4oQxXErjwBT/crBUlyiMVzeliu69HHWeWRSOdTcNtlc34+1LodA== + dependencies: + mailparser "^3.5.0" + smtp-server "^3.11.0" + source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz" @@ -5819,6 +6074,11 @@ timezone-soft@^1.3.1: resolved "https://registry.yarnpkg.com/timezone-soft/-/timezone-soft-1.3.1.tgz#0e994067cbccc76a9c16b71fd8f2f94394be5b0d" integrity sha512-mphMogFJzQy6UIpl/UgKLSNbhmLtdgbz866TnqJ/CnWnc+7dsNyAe8nPwVdOW3Mf8nT0lA32MW/gYhAl4/RkVg== +tlds@1.236.0: + version "1.236.0" + resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.236.0.tgz#a118eebe33261c577e3a3025144faeabb7dd813c" + integrity sha512-oP2PZ3KeGlgpHgsEfrtva3/K9kzsJUNliQSbCfrJ7JMCWFoCdtG+9YMq/g2AnADQ1v5tVlbtvKJZ4KLpy/P6MA== + tlds@^1.199.0: version "1.231.0" resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.231.0.tgz#93880175cd0a06fdf7b5b5b9bcadff9d94813e39"