mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-23 22:06:22 +02:00
Update login and registration (#437)
This commit is contained in:
parent
4e67254022
commit
29eb477792
56 changed files with 1788 additions and 695 deletions
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
|
@ -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
111
declarations/smpt-tester.d.ts
vendored
Normal 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
26
docker-compose.e2e.yml
Normal 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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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" }],
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "ذخیره",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "저장하기",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Сохранить",
|
||||
|
|
|
@ -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ť",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "保存",
|
||||
|
|
18
src/components/auth/auth-layout.tsx
Normal file
18
src/components/auth/auth-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
376
src/components/auth/login-form.tsx
Normal file
376
src/components/auth/login-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
87
src/components/auth/login-modal.tsx
Normal file
87
src/components/auth/login-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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 }) => (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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={
|
||||
|
|
|
@ -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")} →
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -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
19
src/pages/logout.tsx
Normal 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;
|
|
@ -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
33
src/pages/register.tsx
Normal 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);
|
|
@ -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
177
src/server/routers/auth.ts
Normal 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 };
|
||||
}),
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -9,3 +9,5 @@ export const randomid = customAlphabet(
|
|||
"0123456789abcdefghijklmnopqrstuvwxyz",
|
||||
12,
|
||||
);
|
||||
|
||||
export const generateOtp = customAlphabet("0123456789", 6);
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
314
templates/email-verification.ts
Normal file
314
templates/email-verification.ts
Normal 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.͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ‌  ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ‌  ͏ ͏ ͏ ͏
|
||||
͏
|
||||
</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;
|
||||
"
|
||||
>
|
||||
‌
|
||||
</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
|
||||
>
|
||||
•
|
||||
<a
|
||||
href="https://twitter.com/ralllyco"
|
||||
class="hover-underline"
|
||||
style="color: #6366f1; text-decoration-line: none"
|
||||
>Twitter</a
|
||||
>
|
||||
•
|
||||
<a
|
||||
href="https://github.com/lukevella/rallly"
|
||||
class="hover-underline"
|
||||
style="color: #6366f1; text-decoration-line: none"
|
||||
>Github</a
|
||||
>
|
||||
•
|
||||
<a
|
||||
href="https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E"
|
||||
class="hover-underline"
|
||||
style="color: #6366f1; text-decoration-line: none"
|
||||
>Donate</a
|
||||
>
|
||||
•
|
||||
<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;
|
|
@ -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.͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌
|
||||
 ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌
|
||||
 ͏ ͏ ͏ ͏ ͏
|
||||
</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;"> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">Log me in →
|
||||
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;"> </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;">
|
||||
‌
|
||||
</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>
|
||||
•
|
||||
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
|
||||
•
|
||||
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
|
||||
•
|
||||
<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>
|
|
@ -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;"> </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;"> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">Go to poll →
|
||||
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;"> </i><![endif]-->
|
||||
</a>
|
||||
|
|
|
@ -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;"> </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;"> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">Verify your email →
|
||||
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;"> </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>
|
||||
|
|
110
tests/authentication.spec.ts
Normal file
110
tests/authentication.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
278
yarn.lock
|
@ -1296,13 +1296,13 @@
|
|||
"@nodelib/fs.scandir" "2.1.4"
|
||||
fastq "^1.6.0"
|
||||
|
||||
"@playwright/test@^1.28.1":
|
||||
version "1.29.2"
|
||||
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.29.2.tgz#c48184721d0f0b7627a886e2ec42f1efb2be339d"
|
||||
integrity sha512-+3/GPwOgcoF0xLz/opTnahel1/y42PdcgZ4hs+BZGIUjtmEFSXGg+nFoaH3NSmuc7a6GSFwXDJ5L7VXpqzigNg==
|
||||
"@playwright/test@^1.30.0":
|
||||
version "1.30.0"
|
||||
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.30.0.tgz#8c0c4930ff2c7be7b3ec3fd434b2a3b4465ed7cb"
|
||||
integrity sha512-SVxkQw1xvn/Wk/EvBnqWIq6NLo1AppwbYOjNLmyU0R1RoQ3rLEBtmjTnElcnz8VEtn11fptj1ECxK0tgURhajw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
playwright-core "1.29.2"
|
||||
playwright-core "1.30.0"
|
||||
|
||||
"@polka/url@^1.0.0-next.20":
|
||||
version "1.0.0-next.21"
|
||||
|
@ -1573,6 +1573,14 @@
|
|||
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.1.4.tgz#0c8b74c50f29ee44f423f7416829c0bf8bb5eb27"
|
||||
integrity sha512-LwzQKA4vzIct1zNZzBmRKI9QuNpLgTQMEjsQLf3BXuGYb3QPTP4Yjf6mkdX+X1mYttZ808QpOwAzZjv28kq7DA==
|
||||
|
||||
"@selderee/plugin-htmlparser2@^0.10.0":
|
||||
version "0.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.10.0.tgz#8a304d18df907e086f3cfc71ea0ced52d6524430"
|
||||
integrity sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==
|
||||
dependencies:
|
||||
domhandler "^5.0.3"
|
||||
selderee "^0.10.0"
|
||||
|
||||
"@sentry/browser@7.33.0":
|
||||
version "7.33.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.33.0.tgz#0360fd323afda1066734b6d175e55ca1c3264898"
|
||||
|
@ -2456,6 +2464,11 @@ balanced-match@^1.0.0:
|
|||
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
|
||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
|
||||
base32.js@0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.1.0.tgz#b582dec693c2f11e893cf064ee6ac5b6131a2202"
|
||||
integrity sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==
|
||||
|
||||
binary-extensions@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz"
|
||||
|
@ -2560,6 +2573,31 @@ chalk@^4.1.0:
|
|||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
cheerio-select@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4"
|
||||
integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==
|
||||
dependencies:
|
||||
boolbase "^1.0.0"
|
||||
css-select "^5.1.0"
|
||||
css-what "^6.1.0"
|
||||
domelementtype "^2.3.0"
|
||||
domhandler "^5.0.3"
|
||||
domutils "^3.0.1"
|
||||
|
||||
cheerio@^1.0.0-rc.12:
|
||||
version "1.0.0-rc.12"
|
||||
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683"
|
||||
integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==
|
||||
dependencies:
|
||||
cheerio-select "^2.1.0"
|
||||
dom-serializer "^2.0.0"
|
||||
domhandler "^5.0.3"
|
||||
domutils "^3.0.1"
|
||||
htmlparser2 "^8.0.1"
|
||||
parse5 "^7.0.0"
|
||||
parse5-htmlparser2-tree-adapter "^7.0.0"
|
||||
|
||||
chokidar@^3.5.3:
|
||||
version "3.5.3"
|
||||
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz"
|
||||
|
@ -2747,6 +2785,17 @@ css-select@^4.1.3:
|
|||
domutils "^2.8.0"
|
||||
nth-check "^2.0.1"
|
||||
|
||||
css-select@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6"
|
||||
integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==
|
||||
dependencies:
|
||||
boolbase "^1.0.0"
|
||||
css-what "^6.1.0"
|
||||
domhandler "^5.0.2"
|
||||
domutils "^3.0.1"
|
||||
nth-check "^2.0.1"
|
||||
|
||||
css-tree@^1.1.2, css-tree@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz"
|
||||
|
@ -2760,6 +2809,11 @@ css-what@^5.1.0:
|
|||
resolved "https://registry.npmjs.org/css-what/-/css-what-5.1.0.tgz"
|
||||
integrity sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==
|
||||
|
||||
css-what@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
|
||||
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
|
||||
|
||||
cssesc@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
|
||||
|
@ -2937,11 +2991,25 @@ dom-serializer@^1.0.1:
|
|||
domhandler "^4.2.0"
|
||||
entities "^2.0.0"
|
||||
|
||||
dom-serializer@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
|
||||
integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
|
||||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
domhandler "^5.0.2"
|
||||
entities "^4.2.0"
|
||||
|
||||
domelementtype@^2.0.1, domelementtype@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz"
|
||||
integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
|
||||
|
||||
domelementtype@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
|
||||
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
|
||||
|
||||
domhandler@^4.2.0, domhandler@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.npmjs.org/domhandler/-/domhandler-4.3.0.tgz"
|
||||
|
@ -2949,6 +3017,13 @@ domhandler@^4.2.0, domhandler@^4.3.0:
|
|||
dependencies:
|
||||
domelementtype "^2.2.0"
|
||||
|
||||
domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3:
|
||||
version "5.0.3"
|
||||
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
|
||||
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
|
||||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
|
||||
domutils@^2.8.0:
|
||||
version "2.8.0"
|
||||
resolved "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz"
|
||||
|
@ -2958,6 +3033,15 @@ domutils@^2.8.0:
|
|||
domelementtype "^2.2.0"
|
||||
domhandler "^4.2.0"
|
||||
|
||||
domutils@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.0.1.tgz#696b3875238338cb186b6c0612bd4901c89a4f1c"
|
||||
integrity sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==
|
||||
dependencies:
|
||||
dom-serializer "^2.0.0"
|
||||
domelementtype "^2.3.0"
|
||||
domhandler "^5.0.1"
|
||||
|
||||
duplexer@^0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
|
||||
|
@ -2978,6 +3062,11 @@ emoji-regex@^9.2.2:
|
|||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
|
||||
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
|
||||
|
||||
encoding-japanese@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/encoding-japanese/-/encoding-japanese-2.0.0.tgz#fa0226e5469e7b5b69a04fea7d5481bd1fa56936"
|
||||
integrity sha512-++P0RhebUC8MJAwJOsT93dT+5oc5oPImp1HubZpAuCZ5kTLnhuuBhKHj2jJeO/Gj93idPBWmIuQ9QWMe5rX3pQ==
|
||||
|
||||
enquirer@^2.3.5:
|
||||
version "2.3.6"
|
||||
resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz"
|
||||
|
@ -2995,6 +3084,11 @@ entities@^3.0.1:
|
|||
resolved "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz"
|
||||
integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==
|
||||
|
||||
entities@^4.2.0, entities@^4.3.0, entities@^4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174"
|
||||
integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==
|
||||
|
||||
error-ex@^1.2.0, error-ex@^1.3.1:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz"
|
||||
|
@ -3790,6 +3884,11 @@ has@^1.0.3:
|
|||
dependencies:
|
||||
function-bind "^1.1.1"
|
||||
|
||||
he@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
|
||||
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
|
||||
|
||||
hey-listen@^1.0.8:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
|
||||
|
@ -3814,6 +3913,27 @@ html-parse-stringify@^3.0.1:
|
|||
dependencies:
|
||||
void-elements "3.1.0"
|
||||
|
||||
html-to-text@9.0.3:
|
||||
version "9.0.3"
|
||||
resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-9.0.3.tgz#331368f32fcb270c59dbd3a7fdb32813d2a490bc"
|
||||
integrity sha512-hxDF1kVCF2uw4VUJ3vr2doc91pXf2D5ngKcNviSitNkhP9OMOaJkDrFIFL6RMvko7NisWTEiqGpQ9LAxcVok1w==
|
||||
dependencies:
|
||||
"@selderee/plugin-htmlparser2" "^0.10.0"
|
||||
deepmerge "^4.2.2"
|
||||
dom-serializer "^2.0.0"
|
||||
htmlparser2 "^8.0.1"
|
||||
selderee "^0.10.0"
|
||||
|
||||
htmlparser2@^8.0.1:
|
||||
version "8.0.1"
|
||||
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.1.tgz#abaa985474fcefe269bc761a779b544d7196d010"
|
||||
integrity sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==
|
||||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
domhandler "^5.0.2"
|
||||
domutils "^3.0.1"
|
||||
entities "^4.3.0"
|
||||
|
||||
https-proxy-agent@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2"
|
||||
|
@ -3855,6 +3975,13 @@ i18next@^22.0.4:
|
|||
dependencies:
|
||||
"@babel/runtime" "^7.17.2"
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
|
||||
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
|
||||
dependencies:
|
||||
safer-buffer ">= 2.1.2 < 3.0.0"
|
||||
|
||||
ignore@^4.0.6:
|
||||
version "4.0.6"
|
||||
resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz"
|
||||
|
@ -3924,6 +4051,11 @@ invariant@^2.2.4:
|
|||
dependencies:
|
||||
loose-envify "^1.0.0"
|
||||
|
||||
ipv6-normalize@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz#1b3258290d365fa83239e89907dde4592e7620a8"
|
||||
integrity sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==
|
||||
|
||||
iron-session@^6.1.3:
|
||||
version "6.1.3"
|
||||
resolved "https://registry.yarnpkg.com/iron-session/-/iron-session-6.1.3.tgz#c900102560e7d19541a9e6b8bbabc5436b01a230"
|
||||
|
@ -4238,6 +4370,11 @@ language-tags@^1.0.5:
|
|||
dependencies:
|
||||
language-subtag-registry "~0.3.2"
|
||||
|
||||
leac@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
|
||||
integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz"
|
||||
|
@ -4246,6 +4383,26 @@ levn@^0.4.1:
|
|||
prelude-ls "^1.2.1"
|
||||
type-check "~0.4.0"
|
||||
|
||||
libbase64@1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/libbase64/-/libbase64-1.2.1.tgz#fb93bf4cb6d730f29b92155b6408d1bd2176a8c8"
|
||||
integrity sha512-l+nePcPbIG1fNlqMzrh68MLkX/gTxk/+vdvAb388Ssi7UuUN31MI44w4Yf33mM3Cm4xDfw48mdf3rkdHszLNew==
|
||||
|
||||
libmime@5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/libmime/-/libmime-5.2.0.tgz#c4ed5cbd2d9fdd27534543a68bb8d17c658d51d8"
|
||||
integrity sha512-X2U5Wx0YmK0rXFbk67ASMeqYIkZ6E5vY7pNWRKtnNzqjvdYYG8xtPDpCnuUEnPU9vlgNev+JoSrcaKSUaNvfsw==
|
||||
dependencies:
|
||||
encoding-japanese "2.0.0"
|
||||
iconv-lite "0.6.3"
|
||||
libbase64 "1.2.1"
|
||||
libqp "2.0.1"
|
||||
|
||||
libqp@2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/libqp/-/libqp-2.0.1.tgz#b8fed76cc1ea6c9ceff8888169e4e0de70cd5cf2"
|
||||
integrity sha512-Ka0eC5LkF3IPNQHJmYBWljJsw0UvM6j+QdKRbWyCdTmYwvIDE6a7bCm0UkTAL/K+3KXK5qXT/ClcInU01OpdLg==
|
||||
|
||||
lie@3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
|
||||
|
@ -4263,6 +4420,13 @@ lines-and-columns@^1.1.6:
|
|||
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz"
|
||||
integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
|
||||
|
||||
linkify-it@4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec"
|
||||
integrity sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==
|
||||
dependencies:
|
||||
uc.micro "^1.0.1"
|
||||
|
||||
linkify-it@^2.0.3:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf"
|
||||
|
@ -4366,6 +4530,30 @@ magic-string@^0.27.0:
|
|||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.4.13"
|
||||
|
||||
mailparser@^3.5.0:
|
||||
version "3.6.3"
|
||||
resolved "https://registry.yarnpkg.com/mailparser/-/mailparser-3.6.3.tgz#7edcfd9af7931e8a724e97880756477a9ea80f88"
|
||||
integrity sha512-Yi6poKSsZsmjEcUexv3H4w4+TIeyN9u3+TCdC43VK7fe4rUOGDJ3wL4kMhNLiTOScCA1Rpzldv1hcf6g1MLtZQ==
|
||||
dependencies:
|
||||
encoding-japanese "2.0.0"
|
||||
he "1.2.0"
|
||||
html-to-text "9.0.3"
|
||||
iconv-lite "0.6.3"
|
||||
libmime "5.2.0"
|
||||
linkify-it "4.0.1"
|
||||
mailsplit "5.4.0"
|
||||
nodemailer "6.8.0"
|
||||
tlds "1.236.0"
|
||||
|
||||
mailsplit@5.4.0:
|
||||
version "5.4.0"
|
||||
resolved "https://registry.yarnpkg.com/mailsplit/-/mailsplit-5.4.0.tgz#9f4692fadd9013e9ce632147d996931d2abac6ba"
|
||||
integrity sha512-wnYxX5D5qymGIPYLwnp6h8n1+6P6vz/MJn5AzGjZ8pwICWssL+CCQjWBIToOVHASmATot4ktvlLo6CyLfOXWYA==
|
||||
dependencies:
|
||||
libbase64 "1.2.1"
|
||||
libmime "5.2.0"
|
||||
libqp "2.0.1"
|
||||
|
||||
mdn-data@2.0.14:
|
||||
version "2.0.14"
|
||||
resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz"
|
||||
|
@ -4556,6 +4744,16 @@ node-releases@^2.0.1:
|
|||
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz"
|
||||
integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==
|
||||
|
||||
nodemailer@6.7.3:
|
||||
version "6.7.3"
|
||||
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.3.tgz#b73f9a81b9c8fa8acb4ea14b608f5e725ea8e018"
|
||||
integrity sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g==
|
||||
|
||||
nodemailer@6.8.0:
|
||||
version "6.8.0"
|
||||
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.8.0.tgz#804bcc5256ee5523bc914506ee59f8de8f0b1cd5"
|
||||
integrity sha512-EjYvSmHzekz6VNkNd12aUqAco+bOkRe3Of5jVhltqKhEsjw/y0PYPJfp83+s9Wzh1dspYAkUW/YNQ350NATbSQ==
|
||||
|
||||
nodemailer@^6.7.2:
|
||||
version "6.7.2"
|
||||
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.2.tgz#44b2ad5f7ed71b7067f7a21c4fedabaec62b85e0"
|
||||
|
@ -4780,6 +4978,29 @@ parse-json@^5.0.0:
|
|||
json-parse-even-better-errors "^2.3.0"
|
||||
lines-and-columns "^1.1.6"
|
||||
|
||||
parse5-htmlparser2-tree-adapter@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1"
|
||||
integrity sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==
|
||||
dependencies:
|
||||
domhandler "^5.0.2"
|
||||
parse5 "^7.0.0"
|
||||
|
||||
parse5@^7.0.0:
|
||||
version "7.1.2"
|
||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32"
|
||||
integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==
|
||||
dependencies:
|
||||
entities "^4.4.0"
|
||||
|
||||
parseley@^0.11.0:
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.11.0.tgz#1ff817c829a02fcc214c9cc0d96b126d772ee814"
|
||||
integrity sha512-VfcwXlBWgTF+unPcr7yu3HSSA6QUdDaDnrHcytVfj5Z8azAyKBDrYnSIfeSxlrEayndNcLmrXzg+Vxbo6DWRXQ==
|
||||
dependencies:
|
||||
leac "^0.6.0"
|
||||
peberminta "^0.8.0"
|
||||
|
||||
path-exists@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz"
|
||||
|
@ -4817,6 +5038,11 @@ path-type@^4.0.0:
|
|||
resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz"
|
||||
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
|
||||
|
||||
peberminta@^0.8.0:
|
||||
version "0.8.0"
|
||||
resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.8.0.tgz#acf7b105f3d13c8ac28cad81f2f5fe4698507590"
|
||||
integrity sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw==
|
||||
|
||||
picocolors@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
|
||||
|
@ -4844,10 +5070,10 @@ pkg-dir@^2.0.0:
|
|||
dependencies:
|
||||
find-up "^2.1.0"
|
||||
|
||||
playwright-core@1.29.2:
|
||||
version "1.29.2"
|
||||
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.29.2.tgz#2e8347e7e8522409f22b244e600e703b64022406"
|
||||
integrity sha512-94QXm4PMgFoHAhlCuoWyaBYKb92yOcGVHdQLoxQ7Wjlc7Flg4aC/jbFW7xMR52OfXMVkWicue4WXE7QEegbIRA==
|
||||
playwright-core@1.30.0:
|
||||
version "1.30.0"
|
||||
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.30.0.tgz#de987cea2e86669e3b85732d230c277771873285"
|
||||
integrity sha512-7AnRmTCf+GVYhHbLJsGUtskWTE33SwMZkybJ0v6rqR1boxq2x36U7p1vDRV7HO2IwTZgmycracLxPEJI49wu4g==
|
||||
|
||||
popmotion@11.0.3:
|
||||
version "11.0.3"
|
||||
|
@ -5375,6 +5601,11 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
|||
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz"
|
||||
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
||||
|
||||
"safer-buffer@>= 2.1.2 < 3.0.0":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
scheduler@^0.23.0:
|
||||
version "0.23.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
|
||||
|
@ -5387,6 +5618,13 @@ screenfull@^5.1.0:
|
|||
resolved "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz"
|
||||
integrity sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==
|
||||
|
||||
selderee@^0.10.0:
|
||||
version "0.10.0"
|
||||
resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.10.0.tgz#ec83d6044d9026668dc9bd2561acfde99a4e3a1c"
|
||||
integrity sha512-DEL/RW/f4qLw/NrVg97xKaEBC8IpzIG2fvxnzCp3Z4yk4jQ3MXom+Imav9wApjxX2dfS3eW7x0DXafJr85i39A==
|
||||
dependencies:
|
||||
parseley "^0.11.0"
|
||||
|
||||
"semver@2 || 3 || 4 || 5":
|
||||
version "5.7.1"
|
||||
resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz"
|
||||
|
@ -5473,6 +5711,23 @@ smoothscroll-polyfill@^0.4.4:
|
|||
resolved "https://registry.yarnpkg.com/smoothscroll-polyfill/-/smoothscroll-polyfill-0.4.4.tgz#3a259131dc6930e6ca80003e1cb03b603b69abf8"
|
||||
integrity sha512-TK5ZA9U5RqCwMpfoMq/l1mrH0JAR7y7KRvOBx0n2869aLxch+gT9GhN3yUfjiw+d/DiF1mKo14+hd62JyMmoBg==
|
||||
|
||||
smtp-server@^3.11.0:
|
||||
version "3.11.0"
|
||||
resolved "https://registry.yarnpkg.com/smtp-server/-/smtp-server-3.11.0.tgz#8820c191124fab37a8f16c8325a7f1fd38092c4f"
|
||||
integrity sha512-j/W6mEKeMNKuiM9oCAAjm87agPEN1O3IU4cFLT4ZOCyyq3UXN7HiIXF+q7izxJcYSar15B/JaSxcijoPCR8Tag==
|
||||
dependencies:
|
||||
base32.js "0.1.0"
|
||||
ipv6-normalize "1.0.1"
|
||||
nodemailer "6.7.3"
|
||||
|
||||
smtp-tester@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/smtp-tester/-/smtp-tester-2.0.1.tgz#f971a1aaca964a9c9a955dc796e77dd851e1e315"
|
||||
integrity sha512-mJicx4trPmlS2PY/ELG4LIKi8JdOGxnvm0/4oQxXErjwBT/crBUlyiMVzeliu69HHWeWRSOdTcNtlc34+1LodA==
|
||||
dependencies:
|
||||
mailparser "^3.5.0"
|
||||
smtp-server "^3.11.0"
|
||||
|
||||
source-map-js@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
|
||||
|
@ -5819,6 +6074,11 @@ timezone-soft@^1.3.1:
|
|||
resolved "https://registry.yarnpkg.com/timezone-soft/-/timezone-soft-1.3.1.tgz#0e994067cbccc76a9c16b71fd8f2f94394be5b0d"
|
||||
integrity sha512-mphMogFJzQy6UIpl/UgKLSNbhmLtdgbz866TnqJ/CnWnc+7dsNyAe8nPwVdOW3Mf8nT0lA32MW/gYhAl4/RkVg==
|
||||
|
||||
tlds@1.236.0:
|
||||
version "1.236.0"
|
||||
resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.236.0.tgz#a118eebe33261c577e3a3025144faeabb7dd813c"
|
||||
integrity sha512-oP2PZ3KeGlgpHgsEfrtva3/K9kzsJUNliQSbCfrJ7JMCWFoCdtG+9YMq/g2AnADQ1v5tVlbtvKJZ4KLpy/P6MA==
|
||||
|
||||
tlds@^1.199.0:
|
||||
version "1.231.0"
|
||||
resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.231.0.tgz#93880175cd0a06fdf7b5b5b9bcadff9d94813e39"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue