Update login and registration (#437)

This commit is contained in:
Luke Vella 2023-01-30 10:15:25 +00:00 committed by GitHub
parent 4e67254022
commit 29eb477792
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 1788 additions and 695 deletions

View file

@ -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

111
declarations/smpt-tester.d.ts vendored Normal file
View file

@ -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<CaptureOneResponse>;
/**
* 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;
};
}
}

26
docker-compose.e2e.yml Normal file
View file

@ -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

View file

@ -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"
}
}

View file

@ -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" }],

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -3,6 +3,7 @@
"24h": "24-hour",
"addParticipant": "Add participant",
"addTimeOption": "Add time option",
"alreadyRegistered": "Already registered? <a>Login →</a>",
"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 <b>{{name}}</b>",
"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 <b>{{email}}</b> 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 <b>{{save}}</b>",
"share": "Share",
@ -117,13 +115,20 @@
"unlockPoll": "Unlock poll",
"unverifiedMessage": "An email has been sent to <b>{{email}}</b> 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 <b>{{email}}</b> <a>Change</a>",
"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"
}

View file

@ -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",

View file

@ -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": "ذخیره",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "저장하기",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "Сохранить",

View file

@ -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ť",

View file

@ -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",

View file

@ -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": "保存",

View file

@ -0,0 +1,18 @@
import React from "react";
import Logo from "~/public/logo.svg";
export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
return (
<div className="h-full bg-slate-500/10 p-8">
<div className="flex h-full items-start justify-center">
<div className="w-[480px] max-w-full overflow-hidden rounded-lg border bg-white shadow-sm">
<div className="bg-pattern border-b border-t-4 border-t-primary-500 bg-slate-500/5 p-4 text-center sm:p-8">
<Logo className="inline-block h-6 text-primary-500 sm:h-7" />
</div>
<div className="p-4 sm:p-6">{children}</div>
</div>
</div>
</div>
);
};

View file

@ -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<void>;
onResend: () => Promise<void>;
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 (
<div>
<form
onSubmit={handleSubmit(async ({ code }) => {
try {
await onSubmit(code);
} catch {
setError("code", {
type: "not_found",
message: t("wrongVerificationCode"),
});
}
})}
>
<fieldset>
<div className="mb-1 text-2xl font-bold">{t("verifyYourEmail")}</div>
<p className="text-slate-500">
{t("stepSummary", {
current: 2,
total: 2,
})}
</p>
<p>
<Trans
t={t}
i18nKey="verificationCodeSent"
values={{ email }}
components={{
b: <strong className="whitespace-nowrap" />,
a: (
<a
href="#"
onClick={(e) => {
e.preventDefault();
onChange();
}}
/>
),
}}
/>
</p>
<TextInput
autoFocus={true}
proportions="lg"
error={!!formState.errors.code}
className="w-full"
placeholder={t("verificationCodePlaceholder")}
{...register("code", {
validate: requiredString,
})}
/>
{formState.errors.code?.message ? (
<p className="mt-2 text-sm text-rose-500">
{formState.errors.code.message}
</p>
) : null}
<p className="mt-2 text-sm text-slate-400">
{t("verificationCodeHelp")}
</p>
</fieldset>
<div className="space-y-4 sm:flex sm:space-y-0 sm:space-x-3">
<Button
loading={formState.isSubmitting || formState.isSubmitSuccessful}
htmlType="submit"
type="primary"
className="h-12 w-full px-6 sm:w-auto"
>
{t("continue")}
</Button>
<Button
onClick={handleResend}
loading={resendStatus === "busy"}
disabled={resendStatus === "disabled"}
className="h-12 w-full rounded-lg px-4 text-slate-500 transition-colors hover:bg-slate-500/10 active:bg-slate-500/20 sm:w-auto"
>
{t("resendVerificationCode")}
</Button>
</div>
</form>
</div>
);
};
type RegisterFormData = {
name: string;
email: string;
};
export const RegisterForm: React.VoidFunctionComponent<{
onClickLogin?: React.MouseEventHandler;
onRegistered: () => void;
defaultValues?: Partial<RegisterFormData>;
}> = ({ onClickLogin, onRegistered, defaultValues }) => {
const { t } = useTranslation("app");
const { register, handleSubmit, getValues, setError, formState } =
useForm<RegisterFormData>({
defaultValues,
});
const requestRegistration = trpcNext.auth.requestRegistration.useMutation();
const authenticateRegistration =
trpcNext.auth.authenticateRegistration.useMutation();
const [token, setToken] = React.useState<string>();
if (token) {
return (
<VerifyCode
onSubmit={async (code) => {
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 (
<form
onSubmit={handleSubmit(async (data) => {
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);
}
})}
>
<div className="mb-1 text-2xl font-bold">{t("createAnAccount")}</div>
<p className="text-slate-500">
{t("stepSummary", {
current: 1,
total: 2,
})}
</p>
<fieldset className="mb-4">
<label htmlFor="name" className="text-slate-500">
{t("name")}
</label>
<TextInput
className="w-full"
proportions="lg"
autoFocus={true}
error={!!formState.errors.name}
disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")}
{...register("name", { validate: requiredString })}
/>
{formState.errors.name?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.name.message}
</div>
) : null}
</fieldset>
<fieldset className="mb-4">
<label htmlFor="email" className="text-slate-500">
{t("email")}
</label>
<TextInput
className="w-full"
proportions="lg"
error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email", { validate: validEmail })}
/>
{formState.errors.email?.message ? (
<div className="mt-1 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset>
<Button
loading={formState.isSubmitting}
htmlType="submit"
type="primary"
className="h-12 px-6"
>
{t("continue")}
</Button>
<div className="mt-4 border-t pt-4 text-slate-500 sm:text-base">
<Trans
t={t}
i18nKey="alreadyRegistered"
components={{
a: (
<Link
href="/login"
className="text-link"
onClick={onClickLogin}
/>
),
}}
/>
</div>
</form>
);
};
export const LoginForm: React.VoidFunctionComponent<{
onClickRegister?: (
e: React.MouseEvent<HTMLAnchorElement>,
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<string>();
if (token) {
return (
<VerifyCode
onSubmit={async (code) => {
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 (
<form
onSubmit={handleSubmit(async (data) => {
const res = await requestLogin.mutateAsync({
email: data.email,
});
if (!res.token) {
setError("email", {
type: "not_found",
message: t("userNotFound"),
});
} else {
setToken(res.token);
}
})}
>
<div className="mb-1 text-2xl font-bold">{t("login")}</div>
<p className="text-slate-500">
{t("stepSummary", {
current: 1,
total: 2,
})}
</p>
<fieldset className="mb-4">
<label htmlFor="email" className="text-slate-500">
{t("email")}
</label>
<TextInput
className="w-full"
proportions="lg"
autoFocus={true}
error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email", { validate: validEmail })}
/>
{formState.errors.email?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset>
<div className="mb-4 space-y-3">
<Button
loading={formState.isSubmitting}
htmlType="submit"
type="primary"
className="h-12 w-full px-6"
>
{t("continue")}
</Button>
<Link
href="/register"
className="btn-default h-12 w-full px-6"
onClick={(e) => {
onClickRegister?.(e, getValues("email"));
}}
>
{t("notRegistered")}
</Link>
</div>
</form>
);
};

View file

@ -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 (
<div className="w-[420px] max-w-full overflow-hidden rounded-lg bg-white shadow-sm">
<div className="bg-pattern border-b border-t-4 border-t-primary-500 bg-slate-500/5 p-4 text-center sm:p-8">
<Logo className="inline-block h-6 text-primary-500 sm:h-7" />
</div>
<div className="p-4 sm:p-6">
{hasAccount ? (
<RegisterForm
defaultValues={{ email: defaultEmail }}
onRegistered={onDone}
onClickLogin={(e) => {
e.preventDefault();
setHasAccount(false);
}}
/>
) : (
<LoginForm
onAuthenticated={onDone}
onClickRegister={(e, email) => {
e.preventDefault();
setDefaultEmail(email);
setHasAccount(true);
}}
/>
)}
</div>
</div>
);
};
export const useLoginModal = () => {
const modalContext = useModalContext();
const { refresh } = useUser();
const openLoginModal = () => {
modalContext.render({
overlayClosable: false,
showClose: true,
content: function Content({ close }) {
return (
<LoginModal
onDone={() => {
refresh();
close();
}}
/>
);
},
footer: null,
});
};
return { openLoginModal };
};
export const LoginLink = ({
children,
className,
}: React.PropsWithChildren<{ className?: string }>) => {
const { openLoginModal } = useLoginModal();
return (
<Link
href="/login"
onClick={(e) => {
e.preventDefault();
openLoginModal();
}}
className={className}
>
{children}
</Link>
);
};

View file

@ -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<DropdownProps> = ({
);
};
const AnchorLink = React.forwardRef<
HTMLAnchorElement,
{
href?: string;
children?: React.ReactNode;
className?: string;
}
>(function AnchorLink(
{ href = "", className, children, ...forwardProps },
ref,
) {
return (
<Link
ref={ref}
href={href}
passHref
className={clsx(
"font-normal hover:text-white hover:no-underline",
className,
)}
{...forwardProps}
>
{children}
</Link>
);
});
export const DropdownItem: React.VoidFunctionComponent<{
icon?: React.ComponentType<{ className?: string }>;
label?: React.ReactNode;
disabled?: boolean;
href?: string;
onClick?: () => void;
onClick?: React.MouseEventHandler<HTMLElement>;
}> = ({ icon: Icon, label, onClick, disabled, href }) => {
const Element = href ? AnchorLink : "button";
const Element = href ? "a" : "button";
return (
<Menu.Item disabled={disabled}>
{({ active }) => (

View file

@ -33,7 +33,12 @@ const Bonus: React.VoidFunctionComponent = () => {
t={t}
i18nKey={"openSourceDescription"}
components={{
a: <a href="https://github.com/lukevella/rallly" />,
a: (
<a
className="text-link"
href="https://github.com/lukevella/rallly"
/>
),
}}
/>
</div>

View file

@ -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 (
<div className="flex">
<div className="hidden items-center rounded-tl-lg rounded-bl-lg bg-slate-50 p-6 md:flex">
<Magic className="h-24 text-slate-300" />
</div>
<div className="max-w-sm p-6">
<div className="mb-2 text-xl font-semibold">
{t("loginViaMagicLink")}
</div>
{!formState.isSubmitSuccessful ? (
<form
onSubmit={handleSubmit(async ({ email }) => {
posthog.capture("login requested", { email });
await login.mutateAsync({ email, path: router.asPath });
})}
>
<div className="mb-2 text-slate-500">
{t("loginViaMagicLinkDescription")}
</div>
<div className="mb-4">
<input
autoFocus={true}
readOnly={formState.isSubmitting}
className={clsx("input w-full", {
"input-error": formState.errors.email,
})}
placeholder="john.doe@email.com"
{...register("email", { validate: validEmail })}
/>
{formState.errors.email ? (
<div className="mt-1 text-sm text-rose-500">
{t("loginWithValidEmail")}
</div>
) : null}
</div>
<div className="flex space-x-3">
<Button
htmlType="submit"
loading={formState.isSubmitting}
type="primary"
>
{t("loginSendMagicLink")}
</Button>
</div>
</form>
) : (
<div>
<div className="text-slate-500">{t("loginMagicLinkSent")}</div>
<div className="font-mono text-primary-500">
{getValues("email")}
</div>
<div className="mt-2 text-slate-500">{t("loginCheckInbox")}</div>
</div>
)}
</div>
</div>
);
};
export default LoginForm;

View file

@ -28,7 +28,11 @@ export const useModalContext = () => {
const ModalProvider: React.VoidFunctionComponent<ModalProviderProps> = ({
children,
}) => {
const [modals, { push, removeAt, updateAt }] = useList<ModalConfig>([]);
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<ModalProviderProps> = ({
<ModalContext.Provider
value={{
render: (props) => {
push(props);
push({ ...props, id: counter.current++ });
},
}}
>
{children}
{modals.map((props, i) => (
<Modal
key={i}
key={`modal-${props.id}`}
visible={true}
{...props}
content={

View file

@ -144,7 +144,7 @@ const Footer: React.VoidFunctionComponent = () => {
/>
<a
href="https://github.com/lukevella/rallly/wiki/Guide-for-translators"
className="inline-flex items-center rounded-md border px-3 py-2 text-xs text-slate-500"
className="inline-flex items-center rounded-md border px-3 py-2 text-xs text-slate-500 hover:border-primary-500 hover:text-primary-500"
>
<Translate className="mr-2 h-5 w-5" />
{t("volunteerTranslator")} &rarr;

View file

@ -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 (
<div className="card my-4 p-0">
<Head>
<title>{t("profileLogin")}</title>
</Head>
<LoginForm />
</div>
);
return null;
}
return (

View file

@ -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<{
</div>
<div className="flex items-center">
{user ? null : (
<button
onClick={openLoginModal}
className="flex w-full cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<LoginLink className="flex w-full cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<Login className="h-5 opacity-75" />
<span className="inline-block">{t("app:login")}</span>
</button>
</LoginLink>
)}
<AnimatePresence initial={false}>
{user ? (
<UserDropdown
openLoginModal={openLoginModal}
placement="bottom-end"
trigger={
<motion.button
@ -126,7 +119,6 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
className,
}) => {
const { t } = useTranslation(["common", "app"]);
console.log("logo", Logo);
return (
<div className={clsx("space-y-1", className)}>
<Link
@ -149,11 +141,13 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
);
};
const UserDropdown: React.VoidFunctionComponent<
DropdownProps & { openLoginModal: () => void }
> = ({ children, openLoginModal, ...forwardProps }) => {
const UserDropdown: React.VoidFunctionComponent<DropdownProps> = ({
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: <LoginForm />,
});
return (
<ModalProvider>
@ -259,8 +247,7 @@ const StandardLayout: React.VoidFunctionComponent<{
className="relative flex min-h-full flex-col bg-gray-50 lg:flex-row"
{...rest}
>
{loginModal}
<MobileNavigation openLoginModal={openLoginModal} />
<MobileNavigation />
<div className="hidden grow px-4 pt-6 pb-5 lg:block">
<div className="sticky top-6 float-right w-48 items-start">
<div className="mb-8 px-3">
@ -298,13 +285,10 @@ const StandardLayout: React.VoidFunctionComponent<{
<Preferences />
</Popover>
{user ? null : (
<button
onClick={openLoginModal}
className="group flex w-full items-center space-x-3 whitespace-nowrap rounded-md px-3 py-1 font-medium text-slate-600 transition-colors hover:bg-slate-500/10 hover:text-slate-600 hover:no-underline active:bg-slate-500/20"
>
<LoginLink className="group flex w-full items-center space-x-3 whitespace-nowrap rounded-md px-3 py-1 font-medium text-slate-600 transition-colors hover:bg-slate-500/10 hover:text-slate-600 hover:no-underline active:bg-slate-500/20">
<Login className="h-5 opacity-75 group-hover:text-primary-500 group-hover:opacity-100" />
<span className="grow text-left">{t("app:login")}</span>
</button>
</LoginLink>
)}
</div>
<AnimatePresence initial={false}>
@ -312,7 +296,6 @@ const StandardLayout: React.VoidFunctionComponent<{
<UserDropdown
className="mb-4 w-full"
placement="bottom-end"
openLoginModal={openLoginModal}
trigger={
<motion.button
initial={{ x: -20, opacity: 0 }}

View file

@ -12,7 +12,7 @@ export const UserContext =
React.createContext<{
user: UserSession & { shortName: string };
refresh: () => void;
logout: () => Promise<void>;
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 }) => {
<UserContext.Provider
value={{
user: { ...user, shortName },
refresh: refetch,
refresh: () => {
return queryClient.whoami.invalidate();
},
ownsObject: ({ userId }) => {
if (userId && user.id === userId) {
return true;
}
return false;
},
logout: async () => {
await logout.mutateAsync();
refetch();
logout: () => {
logout.mutate();
},
}}
>

View file

@ -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 (
<>
<AuthLayout>
<Head>
<title>Logging in</title>
<title>{t("login")}</title>
</Head>
<FullPageLoader>Logging in</FullPageLoader>
</>
<LoginForm
onAuthenticated={async () => {
refresh();
router.replace("/profile");
}}
/>
</AuthLayout>
);
};
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);

19
src/pages/logout.tsx Normal file
View file

@ -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;

View file

@ -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);

33
src/pages/register.tsx Normal file
View file

@ -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 (
<AuthLayout>
<Head>
<title>{t("register")}</title>
</Head>
<RegisterForm
onRegistered={() => {
router.replace("/profile");
}}
/>
</AuthLayout>
);
};
export const getServerSideProps = withSessionSsr(
withPageTranslations(["common", "app"]),
);
export default withSession(Page);

View file

@ -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,
}),
);

177
src/server/routers/auth.ts Normal file
View file

@ -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<RegistrationTokenPayload>({
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<RegistrationTokenPayload>(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<LoginTokenPayload>({
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<LoginTokenPayload>(
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 };
}),
});

View file

@ -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();
},
});

View file

@ -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;

View file

@ -9,3 +9,5 @@ export const randomid = customAlphabet(
"0123456789abcdefghijklmnopqrstuvwxyz",
12,
);
export const generateOtp = customAlphabet("0123456789", 6);

View file

@ -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;
};

View file

@ -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;
}

View file

@ -0,0 +1,314 @@
const template = `<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="format-detection"
content="telephone=no, date=no, address=no, email=no"
/>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Segoe UI", sans-serif;
mso-line-height-rule: exactly;
}
</style>
<![endif]-->
<title>Please verify your email address</title>
<style>
.hover-underline:hover {
text-decoration-line: underline !important;
}
.hover-no-underline:hover {
text-decoration-line: none !important;
}
@media (max-width: 600px) {
.sm-w-full {
width: 100% !important;
}
.sm-py-8 {
padding-top: 32px !important;
padding-bottom: 32px !important;
}
.sm-px-6 {
padding-left: 24px !important;
padding-right: 24px !important;
}
}
</style>
</head>
<body
style="
margin: 0;
width: 100%;
padding: 0;
word-break: break-word;
-webkit-font-smoothing: antialiased;
background-color: #fff;
"
>
<div style="display: none">
Use the 6-digit code provided to complete the verification process.&#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &zwnj; &#160;&#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &zwnj; &#160;&#847; &#847; &#847; &#847;
&#847;
</div>
<div
role="article"
aria-roledescription="email"
aria-label="Please verify your email address"
lang="en"
>
<table
style="
width: 100%;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI',
sans-serif;
"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td
align="center"
class="sm-py-8"
style="padding-top: 64px; padding-bottom: 64px"
>
<table
class="sm-w-full"
style="width: 480px"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td
style="
padding-left: 32px;
padding-right: 32px;
color: #334155;
"
>
<a href="<%= it.homePageUrl %>">
<img
src="<%= it.homePageUrl %>/logo.png"
width="150"
alt="Rallly"
style="
max-width: 100%;
vertical-align: middle;
line-height: 100%;
border: 0;
"
/>
</a>
</td>
</tr>
<tr>
<td
align="center"
class="sm-px-6"
style="
padding: 32px;
text-align: left;
font-size: 16px;
line-height: 24px;
color: #475569;
"
>
<table
style="width: 100%"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td>
<p
style="
margin: 0;
margin-bottom: 32px;
line-height: 24px;
"
>
Hey <strong id="name"><%= it.name %></strong>,
</p>
<div
style="
margin-bottom: 32px;
border-radius: 8px;
background-color: #f9fafb;
padding-top: 16px;
padding-bottom: 16px;
text-align: center;
"
>
<p
style="
margin: 0;
margin-bottom: 16px;
line-height: 24px;
"
>
Your 6-digit code is:
</p>
<p
style="
margin: 0;
margin-bottom: 16px;
text-align: center;
font-size: 30px;
font-weight: 700;
line-height: 32px;
letter-spacing: 8px;
color: #1e293b;
"
id="code"
>
<%= it.code %>
</p>
<p
style="
margin: 0;
text-align: center;
line-height: 24px;
"
>
This code is valid for 10 minutues.
</p>
</div>
<p style="margin: 0; line-height: 24px">
Use this code to complete the verification process.
</p>
</td>
</tr>
<tr>
<td>
<table
style="width: 100%"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td style="padding-top: 32px; padding-bottom: 32px">
<div
style="
height: 1px;
background-color: #e5e7eb;
line-height: 1px;
"
>
&zwnj;
</div>
</td>
</tr>
</table>
<p style="margin: 0; text-align: center">
Not sure why you received this email? Please
<a
href="mailto:<%= it.supportEmail %>"
class="hover-no-underline"
style="
color: #6366f1;
text-decoration-line: underline;
"
>let us know</a
>.
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td
class="sm-px-6"
style="
padding-left: 32px;
padding-right: 32px;
text-align: center;
font-size: 14px;
color: #4b5563;
"
>
<p style="cursor: default">
<a
href="<%= it.homePageUrl %>"
class="hover-underline"
style="color: #6366f1; text-decoration-line: none"
>Home</a
>
&bull;
<a
href="https://twitter.com/ralllyco"
class="hover-underline"
style="color: #6366f1; text-decoration-line: none"
>Twitter</a
>
&bull;
<a
href="https://github.com/lukevella/rallly"
class="hover-underline"
style="color: #6366f1; text-decoration-line: none"
>Github</a
>
&bull;
<a
href="https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E"
class="hover-underline"
style="color: #6366f1; text-decoration-line: none"
>Donate</a
>
&bull;
<a
href="mailto:<%= it.supportEmail %>"
class="hover-underline"
style="color: #6366f1; text-decoration-line: none"
>Contact</a
>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>`;
export default template;

View file

@ -1,148 +0,0 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Segoe UI", sans-serif;
mso-line-height-rule: exactly;
}
</style>
<![endif]-->
<title>Login with your email</title>
<style>
.hover-bg-indigo-400:hover {
background-color: #818cf8 !important;
}
.hover-underline:hover {
text-decoration: underline !important;
}
.hover-no-underline:hover {
text-decoration: none !important;
}
@media (max-width: 600px) {
.sm-w-full {
width: 100% !important;
}
.sm-py-32 {
padding-top: 32px !important;
padding-bottom: 32px !important;
}
.sm-px-24 {
padding-left: 24px !important;
padding-right: 24px !important;
}
}
</style>
</head>
<body style="margin: 0; width: 100%; padding: 0; word-break: break-word; -webkit-font-smoothing: antialiased; background-color: #f3f4f6;">
<div style="display: none;">
Please click the link below to verify your email address.&#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &zwnj;
&#160;&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &zwnj;
&#160;&#847; &#847; &#847; &#847; &#847;
</div>
<div role="article" aria-roledescription="email" aria-label="Login with your email" lang="en">
<table style="width: 100%; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="background-color: #f3f4f6;">
<table class="sm-w-full" style="width: 600px;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="sm-py-32 sm-px-24" style="padding-left: 48px; padding-right: 48px; padding-top: 36px; padding-bottom: 36px; text-align: center;">
<a href="<%= it.homePageUrl %>">
<img src="<%= it.homePageUrl %>/logo.png" width="150" alt="Rallly" style="max-width: 100%; vertical-align: middle; line-height: 100%; border: 0;">
</a>
</td>
</tr>
<tr>
<td align="center" class="sm-px-24">
<table style="width: 100%;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="sm-px-24" style="border-radius: 4px; background-color: #ffffff; padding: 36px; text-align: left; font-size: 16px; line-height: 24px; color: #1f2937;">
<p style="margin-bottom: 8px;">Hey there,</p>
<p style="margin-bottom: 8px;">
To login with your email please click the button below:
</p>
<p style="margin-bottom: 24px;"></p>
<div style="line-height: 100%;">
<a href="<%= it.loginUrl %>" 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;"> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%; mso-text-raise: 26pt;">&nbsp;</i><![endif]-->
<span style="mso-text-raise: 16px">Log me in &rarr;
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;">&nbsp;</i><![endif]-->
</a>
</div>
<table style="width: 100%;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="padding-top: 32px; padding-bottom: 32px;">
<div style="height: 1px; background-color: #e5e7eb; line-height: 1px;">
&zwnj;
</div>
</td>
</tr>
</table>
<p>
Not sure why you received this email? Please
<a href="mailto:<%= it.supportEmail %>" class="hover-no-underline" style="color: #6366f1; text-decoration: underline;">let us know</a>.
</p>
</td>
</tr>
<tr>
<td style="height: 48px;"></td>
</tr>
<tr>
<td style="padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #4b5563;">
<p style="margin-bottom: 4px; text-transform: uppercase;">RALLLY</p>
<p style="font-style: italic;">Collaborative Scheduling</p>
<p style="cursor: default;">
<a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a>
&bull;
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
&bull;
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
&bull;
<a href="mailto:<%= it.supportEmail %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Contact</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>

View file

@ -99,7 +99,7 @@ const template = `<!DOCTYPE html>
</p>
<p style="margin-bottom: 24px;"></p>
<div style="line-height: 100%;">
<a href="<%= it.pollUrl %>" 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;"> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%; mso-text-raise: 26pt;">&nbsp;</i><![endif]-->
<a id="pollUrl href="<%= it.pollUrl %>" 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;"> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%; mso-text-raise: 26pt;">&nbsp;</i><![endif]-->
<span style="mso-text-raise: 16px">Go to poll &rarr;
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;">&nbsp;</i><![endif]-->
</a>

View file

@ -100,7 +100,7 @@ const template = `<!DOCTYPE html>
</p>
<p style="margin-bottom: 24px;"></p>
<div style="margin-bottom: 24px; line-height: 100%;">
<a href="<%= it.verifyEmailUrl %>" 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;"> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%; mso-text-raise: 26pt;">&nbsp;</i><![endif]-->
<a id="verifyEmailUrl" href="<%= it.verifyEmailUrl %>" 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;"> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%; mso-text-raise: 26pt;">&nbsp;</i><![endif]-->
<span style="mso-text-raise: 16px">Verify your email &rarr;
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;">&nbsp;</i><![endif]-->
</a>
@ -110,7 +110,7 @@ const template = `<!DOCTYPE html>
future 😉
</p>
<p style="font-weight: 500;">
<a href="<%= it.pollUrl %>" style="display: inline-block; background-color: #eef2ff; padding: 8px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: #6366f1; text-decoration: none;">
<a id="pollUrl href="<%= it.pollUrl %>" 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 %>
</a>
</p>

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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'");
});
});

278
yarn.lock
View file

@ -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"