From 72ba61ec4e0b180050deba7fda4eedcddaaabc3c Mon Sep 17 00:00:00 2001 From: Mel <97147377+MelissaAutumn@users.noreply.github.com> Date: Tue, 2 Jul 2024 08:44:06 -0700 Subject: [PATCH] Waiting List (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add InviteBucket model and migration: * Add model and migration * Add invite bucket factory * Add simple test to ensure relationships work as intended * Update test_delete_account to include invite and invite_bucket (and external connections) * Add route to add an email to the invite bucket * InviteBucket -> WaitingList * Add email_verified property to WaitingList * Add Confirm Email mail for WaitingList * Includes a confirm and leave action * Include signed tokens to verify email links * Add tests for the waiting list functions * Downgrade eslint back to 8 * Hookup login screen to allow joining the wait list * Add a waiting list action view with messages for confirming or leaving wait list * 🌐 Update German translation --------- Co-authored-by: Andreas MĂŒller --- backend/src/appointment/controller/data.py | 14 +- backend/src/appointment/controller/mailer.py | 23 ++ backend/src/appointment/database/models.py | 15 +- .../src/appointment/database/repo/invite.py | 50 ++++- backend/src/appointment/database/schemas.py | 14 +- .../src/appointment/exceptions/validation.py | 11 + backend/src/appointment/l10n/de/email.ftl | 30 +++ backend/src/appointment/l10n/en/email.ftl | 18 ++ backend/src/appointment/main.py | 2 + ...-a9ca5a4325ec_create_waiting_list_table.py | 35 +++ backend/src/appointment/routes/api.py | 3 +- backend/src/appointment/routes/auth.py | 18 +- .../src/appointment/routes/waiting_list.py | 81 +++++++ backend/src/appointment/tasks/emails.py | 14 +- .../templates/email/confirm_email.jinja2 | 37 +++ backend/test/conftest.py | 1 + backend/test/factory/waiting_list_factory.py | 23 ++ backend/test/integration/test_waiting_list.py | 191 ++++++++++++++++ backend/test/unit/test_data.py | 22 +- backend/test/unit/test_models.py | 40 ++++ frontend/package.json | 2 +- frontend/src/App.vue | 7 +- frontend/src/definitions.js | 11 + frontend/src/elements/AlertBox.vue | 3 + frontend/src/elements/arts/ArtLeave.vue | 6 + frontend/src/locales/de.json | 16 ++ frontend/src/locales/en.json | 16 ++ frontend/src/router.ts | 7 +- frontend/src/views/LoginView.vue | 185 ++++++++++----- frontend/src/views/WaitingListActionView.vue | 99 ++++++++ frontend/yarn.lock | 211 +++++++++++------- 31 files changed, 1054 insertions(+), 151 deletions(-) create mode 100644 backend/src/appointment/migrations/versions/2024_06_26_2202-a9ca5a4325ec_create_waiting_list_table.py create mode 100644 backend/src/appointment/routes/waiting_list.py create mode 100644 backend/src/appointment/templates/email/confirm_email.jinja2 create mode 100644 backend/test/factory/waiting_list_factory.py create mode 100644 backend/test/integration/test_waiting_list.py create mode 100644 frontend/src/elements/arts/ArtLeave.vue create mode 100644 frontend/src/views/WaitingListActionView.vue diff --git a/backend/src/appointment/controller/data.py b/backend/src/appointment/controller/data.py index 1a62d8110..71e066c93 100644 --- a/backend/src/appointment/controller/data.py +++ b/backend/src/appointment/controller/data.py @@ -3,7 +3,7 @@ from io import StringIO, BytesIO from zipfile import ZipFile -from ..database import repo +from ..database import repo, models from ..database.schemas import Subscriber from ..exceptions.account_api import AccountDeletionPartialFail, AccountDeletionSubscriberFail from ..l10n import l10n @@ -45,6 +45,8 @@ def download(db, subscriber: Subscriber): external_connections = subscriber.external_connections schedules = repo.schedule.get_by_subscriber(db, subscriber.id) availability = [repo.schedule.get_availability(db, schedule.id) for schedule in schedules] + invite = repo.invite.get_by_subscriber(db, subscriber.id) + waiting_list = invite.waiting_list # Convert models to csv attendee_buffer = model_to_csv_buffer(attendees) @@ -54,6 +56,8 @@ def download(db, subscriber: Subscriber): slot_buffer = model_to_csv_buffer(slots) external_connections_buffer = model_to_csv_buffer(external_connections) schedules_buffer = model_to_csv_buffer(schedules) + invite_buffer = model_to_csv_buffer([invite]) + waiting_list_buffer = model_to_csv_buffer([waiting_list]) # Unique behaviour because we can have lists of lists..too annoying to not do it this way. availability_buffer = '' @@ -71,6 +75,8 @@ def download(db, subscriber: Subscriber): data_zip.writestr('external_connection.csv', external_connections_buffer.getvalue()) data_zip.writestr('schedules.csv', schedules_buffer.getvalue()) data_zip.writestr('availability.csv', availability_buffer) + data_zip.writestr('invite.csv', invite_buffer.getvalue()) + data_zip.writestr('waiting_list.csv', waiting_list_buffer.getvalue()) data_zip.writestr( 'readme.txt', l10n('account-data-readme', {'download_time': datetime.datetime.now(datetime.UTC)}) ) @@ -90,12 +96,18 @@ def delete_account(db, subscriber: Subscriber): l10n('account-delete-fail'), ) + # A list of connected account data, if any value is True then we've failed empty_check = [ len(repo.attendee.get_by_subscriber(db, subscriber.id)), len(repo.slot.get_by_subscriber(db, subscriber.id)), len(repo.appointment.get_by_subscriber(db, subscriber.id)), len(repo.calendar.get_by_subscriber(db, subscriber.id)), len(repo.schedule.get_by_subscriber(db, subscriber.id)), + len(repo.external_connection.get_by_type(db, subscriber.id, models.ExternalConnectionType.fxa)), + len(repo.external_connection.get_by_type(db, subscriber.id, models.ExternalConnectionType.google)), + len(repo.external_connection.get_by_type(db, subscriber.id, models.ExternalConnectionType.zoom)), + repo.invite.get_by_subscriber(db, subscriber.id), + repo.invite.get_waiting_list_entry_by_email(db, subscriber.email) ] # Check if we have any left-over subscriber data diff --git a/backend/src/appointment/controller/mailer.py b/backend/src/appointment/controller/mailer.py index d9d10b0eb..d97d29931 100644 --- a/backend/src/appointment/controller/mailer.py +++ b/backend/src/appointment/controller/mailer.py @@ -273,3 +273,26 @@ def text(self): def html(self): return get_template('new_account.jinja2').render(homepage_url=os.getenv('FRONTEND_URL')) + + +class ConfirmYourEmailMail(Mailer): + def __init__(self, confirm_url, decline_url, *args, **kwargs): + default_kwargs = {'subject': l10n('confirm-email-mail-subject')} + self.confirm_url = confirm_url + self.decline_url = decline_url + super(ConfirmYourEmailMail, self).__init__(*args, **default_kwargs, **kwargs) + + def text(self): + return l10n( + 'confirm-email-mail-plain', + { + 'confirm_email_url': self.confirm_url, + 'decline_email_url': self.decline_url, + }, + ) + + def html(self): + return get_template('confirm_email.jinja2').render( + confirm_email_url=self.confirm_url, + decline_email_url=self.decline_url + ) diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index ebe15d580..578297d5e 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -142,7 +142,7 @@ class Subscriber(HasSoftDelete, Base): calendars = relationship('Calendar', cascade='all,delete', back_populates='owner') slots = relationship('Slot', cascade='all,delete', back_populates='subscriber') external_connections = relationship('ExternalConnections', cascade='all,delete', back_populates='owner') - invite: Mapped['Invite'] = relationship('Invite', back_populates='subscriber', uselist=False) + invite: Mapped['Invite'] = relationship('Invite', cascade='all,delete', back_populates='subscriber', uselist=False) def get_external_connection(self, type: ExternalConnectionType) -> 'ExternalConnections': """Retrieves the first found external connection by type or returns None if not found""" @@ -341,6 +341,7 @@ class Invite(Base): status = Column(Enum(InviteStatus), index=True) subscriber: Mapped['Subscriber'] = relationship('Subscriber', back_populates='invite', single_parent=True) + waiting_list: Mapped['WaitingList'] = relationship('WaitingList', cascade='all,delete', back_populates='invite', uselist=False) @property def is_used(self) -> bool: @@ -356,3 +357,15 @@ def is_revoked(self) -> bool: def is_available(self) -> bool: """True if the invite code is not assigned nor revoked""" return self.subscriber_id is None and self.status == InviteStatus.active + + +class WaitingList(Base): + """Holds a list of hopefully future-Appointment users""" + __tablename__ = 'waiting_list' + + id = Column(Integer, primary_key=True, index=True) + email = Column(encrypted_type(String), unique=True, index=True, nullable=False) + email_verified = Column(Boolean, nullable=False, index=True, default=False) + invite_id = Column(Integer, ForeignKey('invites.id'), nullable=True, index=True) + + invite: Mapped['Invite'] = relationship('Invite', back_populates='waiting_list', single_parent=True) diff --git a/backend/src/appointment/database/repo/invite.py b/backend/src/appointment/database/repo/invite.py index 201ceeff4..3617b76b1 100644 --- a/backend/src/appointment/database/repo/invite.py +++ b/backend/src/appointment/database/repo/invite.py @@ -9,7 +9,11 @@ from .. import models, schemas -def get_by_code(db: Session, code: str): +def get_by_subscriber(db: Session, subscriber_id: int) -> models.Invite: + return db.query(models.Invite).filter(models.Invite.subscriber_id == subscriber_id).first() + + +def get_by_code(db: Session, code: str) -> models.Invite: """retrieve invite by code""" return db.query(models.Invite).filter(models.Invite.code == code).first() @@ -69,3 +73,47 @@ def revoke_code(db: Session, code: str): db.commit() db.refresh(db_invite) return True + + +def get_waiting_list_entry_by_email(db: Session, email: str) -> models.WaitingList: + return db.query(models.WaitingList).filter(models.WaitingList.email == email).first() + + +def add_to_waiting_list(db: Session, email: str): + """Add a given email to the invite bucket""" + # Check if they're already in the invite bucket + bucket = get_waiting_list_entry_by_email(db, email) + if bucket: + # Already in waiting list + return False + + bucket = models.WaitingList(email=email) + db.add(bucket) + db.commit() + db.refresh(bucket) + return True + + +def confirm_waiting_list_email(db: Session, email: str): + """Flip the email_verified field to True""" + bucket = get_waiting_list_entry_by_email(db, email) + if not bucket: + return False + + bucket.email_verified = True + db.add(bucket) + db.commit() + db.refresh(bucket) + return True + + +def remove_waiting_list_email(db: Session, email: str): + """Remove an existing email from the waiting list""" + bucket = get_waiting_list_entry_by_email(db, email) + # Already done, lol! + if not bucket: + return True + + db.delete(bucket) + db.commit() + return True diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 8e9f0f578..82ab41f7d 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -8,7 +8,7 @@ from datetime import datetime, date, time from typing import Annotated, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, EmailStr from .models import ( AppointmentStatus, @@ -384,3 +384,15 @@ class TokenData(BaseModel): class SendInviteEmailIn(BaseModel): email: str = Field(title='Email', min_length=1) + + +class JoinTheWaitingList(BaseModel): + email: str = Field(title='Email', min_length=1) + + +class TokenForWaitingList(BaseModel): + token: str = Field(title='Token') + + +class CheckEmail(BaseModel): + email: EmailStr = Field(title='Email', min_length=1) diff --git a/backend/src/appointment/exceptions/validation.py b/backend/src/appointment/exceptions/validation.py index 11628cd9b..d22c96221 100644 --- a/backend/src/appointment/exceptions/validation.py +++ b/backend/src/appointment/exceptions/validation.py @@ -278,3 +278,14 @@ class SubscriberSelfDeleteException(APIException): def get_msg(self): return l10n('subscriber-self-delete') + + +class WaitingListActionFailed(APIException): + """Raise if the waiting list link was valid but failed for some reason""" + + id_code = 'WAITING_LIST_FAIL' + status_code = 400 + + def get_msg(self): + return l10n('unknown-error') + diff --git a/backend/src/appointment/l10n/de/email.ftl b/backend/src/appointment/l10n/de/email.ftl index aa0c73a3b..a4cbe4e77 100644 --- a/backend/src/appointment/l10n/de/email.ftl +++ b/backend/src/appointment/l10n/de/email.ftl @@ -55,6 +55,7 @@ reject-mail-plain = { $owner_name } hat deine Buchungsanfrage fĂŒr dieses Zeitfe {-brand-footer} ## Pending Appointment + pending-mail-subject = deine Buchungsanfrage wartet auf BestĂ€tigung # Variables: # $owner_name (String) - Name of the person who owns the schedule @@ -102,3 +103,32 @@ support-mail-plain = { $requestee_name } ({ $requestee_email }) hat folgende Sup Thema: { $topic } Inhalt: { $details } {-brand-footer} + +## New/Invited Account Email +new-account-mail-subject = Du wurdest zu Thunderbird Appointment eingeladen +new-account-mail-action = Weiter zu Thunderbird Appointment +new-account-mail-html-heading = Du wurdest zu Thunderbird Appointment eingeladen. Logge dich mit dieser E-Mail-Adresse ein um fortzufahren. +# Variables: +# $homepage_url (String) - URL to Thunderbird Appointment +new-account-mail-plain = Du wurdest zu Thunderbird Appointment eingeladen. + Logge dich mit dieser E-Mail-Adresse ein um fortzufahren. + { $homepage_url } + {-brand-footer} + +## Confirm Email for waiting list +confirm-email-mail-subject = BestĂ€tige deine E-Mail-Adresse um der Warteliste beizutreten! +confirm-email-mail-confirm-action = BestĂ€tige deine E-Mail-Adresse +confirm-email-mail-decline-action = Entferne deine E-Mail-Adresse +confirm-email-mail-html-body = Danke fĂŒr Dein Interesse an Thunderbird Appointment. + Bevor wir Dich auf unsere Warteliste setzen, musst Du Deine E-Mail-Adresse unten bestĂ€tigen. +confirm-email-mail-html-body-2 = Hast Du diese E-Mail irrtĂŒmlich erhalten, oder bist nicht mehr interessiert? +# Variables: +# $confirm_email_url (String) - URL to confirm your email +# $decline_email_url (String) - URL to remove the email from the waiting list +confirm-email-mail-plain = Danke fĂŒr Dein Interesse an Thunderbird Appointment. + Bevor wir Dich auf unsere Warteliste setzen, musst Du Deine E-Mail-Adresse ĂŒber den unten stehenden Link bestĂ€tigen. + { $confirm_email_url } + + Hast Du diese E-Mail irrtĂŒmlich erhalten, oder bist nicht mehr interessiert? Folge einfach diesem Link, um Deine E-Mail-Adresse von der Warteliste zu löschen. + { $decline_email_url } + {-brand-footer} diff --git a/backend/src/appointment/l10n/en/email.ftl b/backend/src/appointment/l10n/en/email.ftl index 4f2e0ccf0..4ecb73759 100644 --- a/backend/src/appointment/l10n/en/email.ftl +++ b/backend/src/appointment/l10n/en/email.ftl @@ -114,3 +114,21 @@ new-account-mail-plain = You've been invited to Thunderbird Appointment. Login with this email address to continue. { $homepage_url } {-brand-footer} + +## Confirm Email for waiting list +confirm-email-mail-subject = Confirm your email to join the waiting list! +confirm-email-mail-confirm-action = Confirm your email +confirm-email-mail-decline-action = Remove your email +confirm-email-mail-html-body = Thank you for your interest in Thunderbird Appointment. + Before we add you to our waiting list we need you to confirm your email address below. +confirm-email-mail-html-body-2 = Did you receive this email in error, or are you no longer interested? +# Variables: +# $confirm_email_url (String) - URL to confirm your email +# $decline_email_url (String) - URL to remove the email from the waiting list +confirm-email-mail-plain = Thank you for your interest in Thunderbird Appointment. + Before we add you to our waiting list we need you to confirm your email address at the link below. + { $confirm_email_url } + + Did you receive this email in error, or are you no longer interested? Just follow this link to remove your email from our waiting list. + { $decline_email_url } + {-brand-footer} diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index c4506c463..df3c5bc3a 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -121,6 +121,7 @@ def server(): from .routes import invite from .routes import subscriber from .routes import zoom + from .routes import waiting_list from .routes import webhooks # Hide openapi url (which will also hide docs/redoc) if we're not dev @@ -180,6 +181,7 @@ async def catch_google_refresh_errors(request, exc): app.include_router(schedule.router, prefix='/schedule') app.include_router(invite.router, prefix='/invite') app.include_router(subscriber.router, prefix='/subscriber') + app.include_router(waiting_list.router, prefix='/waiting-list') app.include_router(webhooks.router, prefix='/webhooks') if os.getenv('ZOOM_API_ENABLED'): app.include_router(zoom.router, prefix='/zoom') diff --git a/backend/src/appointment/migrations/versions/2024_06_26_2202-a9ca5a4325ec_create_waiting_list_table.py b/backend/src/appointment/migrations/versions/2024_06_26_2202-a9ca5a4325ec_create_waiting_list_table.py new file mode 100644 index 000000000..7f3a9ec38 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_06_26_2202-a9ca5a4325ec_create_waiting_list_table.py @@ -0,0 +1,35 @@ +"""create waiting list table + +Revision ID: a9ca5a4325ec +Revises: f732d6e597fe +Create Date: 2024-06-26 22:02:19.851617 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import ForeignKey, func + +from appointment.database.models import encrypted_type + +# revision identifiers, used by Alembic. +revision = 'a9ca5a4325ec' +down_revision = 'f732d6e597fe' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'waiting_list', + sa.Column('id', sa.Integer, primary_key=True, index=True), + sa.Column('email', encrypted_type(sa.String), unique=True, index=True, nullable=False), + sa.Column('email_verified', sa.Boolean, nullable=False, index=True, default=False), + sa.Column('invite_id', sa.Integer, ForeignKey('invites.id'), nullable=True, index=True), + sa.Column('time_created', sa.DateTime, server_default=func.now()), + sa.Column('time_updated', sa.DateTime, server_default=func.now()), + ) + + +def downgrade() -> None: + op.drop_table('waiting_list') diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 893c4c08a..3dc4ca560 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -1,7 +1,6 @@ -import enum +import logging import os import secrets -from enum import Enum import requests.exceptions import sentry_sdk diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index 524f95142..1a1084345 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -10,7 +10,7 @@ from sentry_sdk import capture_exception from sqlalchemy.orm import Session -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks from fastapi.responses import RedirectResponse from .. import utils @@ -26,6 +26,7 @@ from ..exceptions import validation from ..exceptions.fxa_api import NotInAllowListException from ..l10n import l10n +from ..tasks.emails import send_confirm_email router = APIRouter() @@ -41,6 +42,21 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): return encoded_jwt +@router.post('/can-login') +def can_login( + data: schemas.CheckEmail, + db: Session = Depends(get_db), + fxa_client: FxaClient = Depends(get_fxa_client) +): + """Determines if a user can go through the login flow""" + if os.getenv('AUTH_SCHEME') == 'fxa': + # This checks if a subscriber exists, or is in allowed list + return fxa_client.is_in_allow_list(db, data.email) + + # There's no waiting list setting on password login + return True + + @router.get('/fxa_login') def fxa_login( request: Request, diff --git a/backend/src/appointment/routes/waiting_list.py b/backend/src/appointment/routes/waiting_list.py new file mode 100644 index 000000000..ff75d38b8 --- /dev/null +++ b/backend/src/appointment/routes/waiting_list.py @@ -0,0 +1,81 @@ +import os + +import sentry_sdk +from sqlalchemy.orm import Session +from fastapi import APIRouter, Depends, BackgroundTasks +from ..database import repo, schemas + +from ..dependencies.database import get_db +from ..exceptions import validation +from ..tasks.emails import send_confirm_email +from itsdangerous import URLSafeSerializer, BadSignature +from secrets import token_bytes +from enum import Enum + +router = APIRouter() + + +class WaitingListAction(Enum): + CONFIRM_EMAIL = 1 + LEAVE = 2 + + +@router.post('/join') +def join_the_waiting_list( + data: schemas.JoinTheWaitingList, background_tasks: BackgroundTasks, db: Session = Depends(get_db) +): + """Join the waiting list!""" + added = repo.invite.add_to_waiting_list(db, data.email) + + # TODO: Replace our signed functionality with this + # Not timed since we don't have a mechanism to re-send these... + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': data.email, 'action': WaitingListAction.CONFIRM_EMAIL.value}) + decline_token = serializer.dumps({'email': data.email, 'action': WaitingListAction.LEAVE.value}) + + # If they were added, send the email + if added: + background_tasks.add_task(send_confirm_email, to=data.email, confirm_token=confirm_token, decline_token=decline_token) + + return added + + +@router.post('/action') +def act_on_waiting_list( + data: schemas.TokenForWaitingList, + db: Session = Depends(get_db) +): + """Perform a waiting list action from a signed token""" + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + + try: + token_data = serializer.loads(data.token) + except BadSignature: + raise validation.InvalidLinkException() + + action = token_data.get('action') + email = token_data.get('email') + + if action is None or email is None: + raise validation.InvalidLinkException() + + if action == WaitingListAction.CONFIRM_EMAIL.value: + success = repo.invite.confirm_waiting_list_email(db, email) + elif action == WaitingListAction.LEAVE.value: + success = repo.invite.remove_waiting_list_email(db, email) + else: + raise validation.InvalidLinkException() + + # This shouldn't happen, but just in case! + if not success: + exception = validation.WaitingListActionFailed() + + # Capture this issue in sentry + sentry_sdk.capture_exception(exception) + + raise exception + + return { + 'action': action, + 'success': success, + } diff --git a/backend/src/appointment/tasks/emails.py b/backend/src/appointment/tasks/emails.py index a6a68a49f..125cc4b65 100644 --- a/backend/src/appointment/tasks/emails.py +++ b/backend/src/appointment/tasks/emails.py @@ -1,3 +1,6 @@ +import os +import urllib.parse + from appointment.controller.mailer import ( PendingRequestMail, ConfirmationMail, @@ -5,7 +8,7 @@ ZoomMeetingFailedMail, RejectionMail, SupportRequestMail, - InviteAccountMail, + InviteAccountMail, ConfirmYourEmailMail, ) @@ -48,3 +51,12 @@ def send_support_email(requestee_name, requestee_email, topic, details): def send_invite_account_email(to): mail = InviteAccountMail(to=to) mail.send() + + +def send_confirm_email(to, confirm_token, decline_token): + base_url = f"{os.getenv('FRONTEND_URL')}/waiting-list" + confirm_url = f"{base_url}/{confirm_token}" + decline_url = f"{base_url}/{decline_token}" + + mail = ConfirmYourEmailMail(to=to, confirm_url=confirm_url, decline_url=decline_url) + mail.send() diff --git a/backend/src/appointment/templates/email/confirm_email.jinja2 b/backend/src/appointment/templates/email/confirm_email.jinja2 new file mode 100644 index 000000000..d5169900b --- /dev/null +++ b/backend/src/appointment/templates/email/confirm_email.jinja2 @@ -0,0 +1,37 @@ + + +

+ {{ l10n('confirm-email-mail-html-body') }} +

+
+ {{ l10n('confirm-email-mail-confirm-action') }} +
+

+ {{ l10n('confirm-email-mail-html-body-2') }} +

+
+ {{ l10n('confirm-email-mail-decline-action') }} +
+ {% include 'includes/footer.jinja2' %} + + diff --git a/backend/test/conftest.py b/backend/test/conftest.py index 30380a72c..9b5826869 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -21,6 +21,7 @@ from factory.slot_factory import make_appointment_slot # noqa: F401 from factory.subscriber_factory import make_subscriber, make_basic_subscriber, make_pro_subscriber # noqa: F401 from factory.invite_factory import make_invite # noqa: F401 +from factory.waiting_list_factory import make_waiting_list # noqa: F401 # Load our env load_dotenv(find_dotenv('.env.test'), override=True) diff --git a/backend/test/factory/waiting_list_factory.py b/backend/test/factory/waiting_list_factory.py new file mode 100644 index 000000000..851e2e899 --- /dev/null +++ b/backend/test/factory/waiting_list_factory.py @@ -0,0 +1,23 @@ +import pytest +from faker import Faker +from appointment.database import models +from defines import FAKER_RANDOM_VALUE, factory_has_value + + +@pytest.fixture +def make_waiting_list(with_db): + fake = Faker() + + def _make_waiting_list(invite_id=None, email=FAKER_RANDOM_VALUE, email_verified=False) -> models.WaitingList: + with with_db() as db: + invite = models.WaitingList( + email=email if factory_has_value(email) else fake.email(), + email_verified=email_verified, + invite_id=invite_id, + ) + db.add(invite) + db.commit() + db.refresh(invite) + return invite + + return _make_waiting_list diff --git a/backend/test/integration/test_waiting_list.py b/backend/test/integration/test_waiting_list.py new file mode 100644 index 000000000..eef94af36 --- /dev/null +++ b/backend/test/integration/test_waiting_list.py @@ -0,0 +1,191 @@ +import os +import pytest +from itsdangerous import URLSafeSerializer +from sqlalchemy.exc import InvalidRequestError + +from appointment.database import models +from unittest.mock import patch + +from appointment.routes.waiting_list import WaitingListAction + + +class TestJoinWaitingList: + def test_success(self, with_db, with_client): + email = 'hello@example.org' + with with_db() as db: + assert not db.query(models.WaitingList).filter(models.WaitingList.email == email).first() + + with patch('fastapi.BackgroundTasks.add_task') as mock: + response = with_client.post('/waiting-list/join', json={'email': email}) + + # Ensure the response was okay! + assert response.status_code == 200, response.json() + assert response.json() is True + + # Ensure our email was inserted + with with_db() as db: + assert db.query(models.WaitingList).filter(models.WaitingList.email == email).first() is not None + + # Ensure we sent out an email + mock.assert_called_once() + + def test_already_in_list(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + with with_db() as db: + assert not db.query(models.WaitingList).filter(models.WaitingList.email == email).first() + + make_waiting_list(email=email) + + with patch('fastapi.BackgroundTasks.add_task') as mock: + response = with_client.post('/waiting-list/join', json={'email': email}) + + # Ensure the response was okay! + assert response.status_code == 200, response.json() + assert response.json() is False + + # Ensure we did not send out an email + mock.assert_not_called() + + +class TestWaitingListActionConfirm: + def assert_email_verified(self, db, waiting_list, success=True): + assert not waiting_list.email_verified + + db.add(waiting_list) + db.refresh(waiting_list) + + if success: + assert waiting_list.email_verified + else: + assert not waiting_list.email_verified + + def test_success(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + waiting_list = make_waiting_list(email=email) + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': WaitingListAction.CONFIRM_EMAIL.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was okay! + assert response.status_code == 200, response.json() + assert response.json() == { "action": WaitingListAction.CONFIRM_EMAIL.value, "success": True } + + with with_db() as db: + self.assert_email_verified(db, waiting_list, success=True) + + def test_bad_secret(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + waiting_list = make_waiting_list(email=email) + + serializer = URLSafeSerializer('wow-a-fake-secret', 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': WaitingListAction.CONFIRM_EMAIL.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was not okay! + assert response.status_code == 400, response.json() + + # They shouldn't be verified before or after the db fetch + with with_db() as db: + self.assert_email_verified(db, waiting_list, success=False) + + def test_bad_token_data_invalid_action(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + waiting_list = make_waiting_list(email=email) + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': 999}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was not okay! + assert response.status_code == 400, response.json() + + # They shouldn't be verified before or after the db fetch + with with_db() as db: + self.assert_email_verified(db, waiting_list, success=False) + + def test_bad_token_data_missing_email(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + waiting_list = make_waiting_list(email=email) + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'action': WaitingListAction.CONFIRM_EMAIL.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was not okay! + assert response.status_code == 400, response.json() + + # They shouldn't be verified before or after the db fetch + with with_db() as db: + self.assert_email_verified(db, waiting_list, success=False) + + def test_bad_token_data_email_not_in_list(self, with_db, with_client): + email = 'hello@example.org' + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': WaitingListAction.CONFIRM_EMAIL.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was not okay! + assert response.status_code == 400, response.json() + + # They shouldn't be verified before or after the db fetch + with with_db() as db: + assert not db.query(models.WaitingList).filter(models.WaitingList.email == email).first() + + +class TestWaitingListActionLeave: + def assert_waiting_list_exists(self, db, waiting_list, success=True): + assert waiting_list + email = waiting_list.email + + db.add(waiting_list) + if success: + with pytest.raises(InvalidRequestError): + db.refresh(waiting_list) + + assert not db.query(models.WaitingList).filter(models.WaitingList.email == email).first() + else: + db.refresh(waiting_list) + assert waiting_list is not None + + def test_success(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + waiting_list = make_waiting_list(email=email) + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': WaitingListAction.LEAVE.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was okay! + assert response.status_code == 200, response.json() + assert response.json() == {"action": WaitingListAction.LEAVE.value, "success": True} + + with with_db() as db: + self.assert_waiting_list_exists(db, waiting_list, success=True) + + def test_bad_token_data_email_not_in_list(self, with_db, with_client, make_waiting_list): + email = 'hello@example.org' + + serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list') + confirm_token = serializer.dumps({'email': email, 'action': WaitingListAction.LEAVE.value}) + + response = with_client.post('/waiting-list/action', json={'token': confirm_token}) + + # Ensure the response was okay! + assert response.status_code == 200, response.json() + assert response.json() == { "action": WaitingListAction.LEAVE.value, "success": True } + + with with_db() as db: + assert not db.query(models.WaitingList).filter(models.WaitingList.email == email).first() diff --git a/backend/test/unit/test_data.py b/backend/test/unit/test_data.py index 84bb6b8cb..32667eae0 100644 --- a/backend/test/unit/test_data.py +++ b/backend/test/unit/test_data.py @@ -1,6 +1,7 @@ from argon2 import PasswordHasher from appointment.controller.data import model_to_csv_buffer, delete_account +from appointment.database import models class TestData: @@ -31,19 +32,36 @@ def test_delete_account( make_schedule, make_caldav_calendar, make_external_connections, + make_invite, + make_waiting_list, ): """Test that our delete account functionality actually deletes everything""" subscriber = make_pro_subscriber() calendar = make_caldav_calendar(subscriber_id=subscriber.id) appointment = make_appointment(calendar_id=calendar.id) schedule = make_schedule(calendar_id=calendar.id) - external_connection = make_external_connections(subscriber_id=subscriber.id) + external_connections = [ + make_external_connections(subscriber_id=subscriber.id, type=models.ExternalConnectionType.fxa), + make_external_connections(subscriber_id=subscriber.id, type=models.ExternalConnectionType.google), + make_external_connections(subscriber_id=subscriber.id, type=models.ExternalConnectionType.zoom), + ] + invite = make_invite(subscriber_id=subscriber.id) + waiting_list = make_waiting_list(email=subscriber.email, invite_id=invite.id) # Get some relationships slots = appointment.slots # Bunch them together into a list. They must have an id field, otherwise assert them manually. - models_to_check = [subscriber, external_connection, calendar, appointment, schedule, *slots] + models_to_check = [ + subscriber, + calendar, + appointment, + schedule, + invite, + waiting_list, + *external_connections, + *slots, + ] with with_db() as db: ret = delete_account(db, subscriber) diff --git a/backend/test/unit/test_models.py b/backend/test/unit/test_models.py index 48e90bebc..d6ca35ed9 100644 --- a/backend/test/unit/test_models.py +++ b/backend/test/unit/test_models.py @@ -1,3 +1,5 @@ +import pytest +from sqlalchemy.exc import IntegrityError from appointment.database import models, repo, schemas @@ -26,3 +28,41 @@ def get_data(): for appointment in appointments: assert len(db.query(models.Appointment).filter(models.Appointment.uuid == appointment.uuid).all()) == 1 + + +class TestWaitingList: + def test_successful_relationship(self, with_db, make_pro_subscriber, make_invite, make_waiting_list): + subscriber = make_pro_subscriber() + invite = make_invite(subscriber_id=subscriber.id) + waiting_list = make_waiting_list(invite_id=invite.id) + + with with_db() as db: + db.add(subscriber) + db.add(invite) + db.add(waiting_list) + + assert waiting_list.invite == invite + assert waiting_list.invite.subscriber == subscriber + + assert invite.waiting_list == waiting_list + assert subscriber.invite.waiting_list == waiting_list + + def test_empty_relationship(self, with_db, make_waiting_list): + waiting_list = make_waiting_list() + + with with_db() as db: + db.add(waiting_list) + + assert not waiting_list.invite + + def test_email_is_unique(self, make_waiting_list): + email = 'greg@example.org' + + waiting_list = make_waiting_list(email=email) + assert waiting_list + + # Raises integrity error due to unique constraint failure + with pytest.raises(IntegrityError): + waiting_list_2 = make_waiting_list(email=email) + assert not waiting_list_2 + diff --git a/frontend/package.json b/frontend/package.json index eaa9d61c5..18762b5fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,7 @@ "@babel/core": "^7.12.16", "@babel/eslint-parser": "^7.12.16", "autoprefixer": "^10.4.12", - "eslint": "^9.5.0", + "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.25.2", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 22a19e39f..24bcbffb5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -14,7 +14,7 @@ :class="{ 'mx-4 min-h-full py-32 lg:mx-8': !routeIsHome && !routeIsPublic, '!pt-24': routeIsHome || isAuthenticated, - 'min-h-full pb-32 pt-8': routeIsPublic, + 'min-h-full pb-32 pt-8': routeIsPublic && !routeHasModal, }" > @@ -137,11 +137,14 @@ const scheduleStore = useScheduleStore(); // true if route can be accessed without authentication const routeIsPublic = computed( - () => ['availability', 'home', 'login', 'post-login', 'confirmation', 'terms', 'privacy'].includes(route.name), + () => ['availability', 'home', 'login', 'post-login', 'confirmation', 'terms', 'privacy', 'waiting-list'].includes(route.name), ); const routeIsHome = computed( () => ['home'].includes(route.name), ); +const routeHasModal = computed( + () => ['login'].includes(route.name), +); // retrieve calendars and appointments after checking login and persisting user to db const getDbData = async () => { diff --git a/frontend/src/definitions.js b/frontend/src/definitions.js index b4425054a..b39df0321 100644 --- a/frontend/src/definitions.js +++ b/frontend/src/definitions.js @@ -262,6 +262,16 @@ export const tableDataButtonType = { caution: 3, }; +/** + * This should match the enum in routes/waiting_list.py + * @enum + * @readonly + */ +export const waitingListAction = { + confirm: 1, + leave: 2, +}; + export default { subscriberLevels, appointmentState, @@ -280,4 +290,5 @@ export default { qalendarSlotDurations, tableDataType, tableDataButtonType, + waitingListAction, }; diff --git a/frontend/src/elements/AlertBox.vue b/frontend/src/elements/AlertBox.vue index 97c46da70..1967b65b6 100644 --- a/frontend/src/elements/AlertBox.vue +++ b/frontend/src/elements/AlertBox.vue @@ -32,6 +32,9 @@ diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 180856034..cfae69eba 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -89,6 +89,7 @@ "addCalendar": "{provider} Kalender hinzufĂŒgen", "addDay": "Tag hinzufĂŒgen", "addTime": "Zeit hinzufĂŒgen", + "addToWaitingList": "Der Warteliste beitreten", "admin-invite-codes-panel": "Einladungen", "admin-subscriber-panel": "Abonnenten", "all": "Alle", @@ -111,6 +112,7 @@ "attendees": "Teilnehmer", "availabilityDay": "VerfĂŒgbarer Tag", "availableDays": "VerfĂŒgbare Tage", + "back": "ZurĂŒck", "bookEvent": "Buchen", "booked": "Gebucht", "bookingLink": "Buchungslink", @@ -177,6 +179,7 @@ "goForward": "Weiter", "google": "Google", "guest": "{count} GĂ€ste | {count} Gast | {count} GĂ€ste", + "home": "Start", "immediately": "Sofort", "inPerson": "Vor Ort", "inviteCode": "Du hast einen Einladungscode?", @@ -235,6 +238,7 @@ "shareMyLink": "Meinen Link teilen", "showSecondaryTimeZone": "Zeige sekundĂ€re Zeitzone", "signInWithGoogle": "Mit Google anmelden", + "signUpWithInviteCode": "Anmeldung mit Einladungscode", "slotLength": "Termindauer", "start": "Start", "startDate": "Startdatum", @@ -358,5 +362,17 @@ "units": { "minutes": "{value} Minuten", "none": "{value}" + }, + "waitingList": { + "confirmHeading": "Deine E-Mail-Adresse wurde bestĂ€tigt!", + "confirmInfo": "Deine E-Mail-Adresse ist nun bestĂ€tigt. Sobald Du ausgewĂ€hlt wurdest, wirst Du einen Einladungscode bekommen.", + "errorHeading": "Es gab ein Problem mit diesem Link", + "errorInfo": "Leider ist dieser Link abgelaufen oder ungĂŒltig.", + "leaveHeading": "Du hast die Warteliste erfolgreich verlassen", + "leaveInfo": "Schade, dass Du gehst. Du kannst Dich jederzeit wieder auf die Warteliste setzen lassen!", + "signUpAlreadyExists": "Du bist bereits auf der Warteliste.", + "signUpCheckYourEmail": "Weitere Informationen findest Du in Deiner E-Mail.", + "signUpHeading": "Nur noch ein Schritt!", + "signUpInfo": "Bevor Du in die Warteliste aufgenommen werden kannst, musst Du Deine E-Mail-Adresse bestĂ€tigen." } } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 3364e4f80..0f44a90d8 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -89,6 +89,7 @@ "addCalendar": "Add {provider} calendar", "addDay": "Add day", "addTime": "Add time", + "addToWaitingList": "Join the waiting list", "admin-invite-codes-panel": "Invites", "admin-subscriber-panel": "Subscribers", "all": "All", @@ -111,6 +112,7 @@ "attendees": "Attendees", "availabilityDay": "Availability Day", "availableDays": "Days", + "back": "Back", "bookEvent": "Book", "booked": "Booked", "bookingLink": "Booking Link", @@ -177,6 +179,7 @@ "goForward": "Go Forward", "google": "Google", "guest": "{count} guests | {count} guest | {count} guests", + "home": "Home", "immediately": "instant", "inPerson": "In person", "inviteCode": "Have an invite code?", @@ -235,6 +238,7 @@ "shareMyLink": "Share my link", "showSecondaryTimeZone": "Show secondary time zone", "signInWithGoogle": "Sign in with Google", + "signUpWithInviteCode": "Sign up with invite code", "slotLength": "Booking Duration", "start": "Start", "startDate": "Start Date", @@ -358,5 +362,17 @@ "units": { "minutes": "{value} minutes", "none": "{value}" + }, + "waitingList": { + "confirmHeading": "Your email is confirmed!", + "confirmInfo": "Your email is now confirmed and if selected you will be contacted about an invite code in the future.", + "errorHeading": "There was a problem with that link", + "errorInfo": "Unfortunately this link is expired or is invalid.", + "leaveHeading": "You successfully left the waiting list", + "leaveInfo": "Sorry to see you go. You're welcome to re-join the waiting list at any time!", + "signUpAlreadyExists": "You are already on the waiting list.", + "signUpCheckYourEmail": "Check your email for more information.", + "signUpHeading": "Just one more step!", + "signUpInfo": "Before you can be added to the waiting list, you need to confirm your email address." } } diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 000e8ee40..76fa96167 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -14,9 +14,9 @@ const AppointmentsView = defineAsyncComponent(() => import('@/views/Appointments const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView.vue')); const ProfileView = defineAsyncComponent(() => import('@/views/ProfileView.vue')); const LegalView = defineAsyncComponent(() => import('@/views/LegalView.vue')); +const WaitingListActionView = defineAsyncComponent(() => import('@/views/WaitingListActionView.vue')); const SubscriberPanelView = defineAsyncComponent(() => import('@/views/admin/SubscriberPanelView.vue')); const InviteCodePanelView = defineAsyncComponent(() => import('@/views/admin/InviteCodePanelView.vue')); - /** * Defined routes for Thunderbird Appointment * Note: All routes require authentication unless otherwise specified in App.vue::routeIsPublic @@ -92,6 +92,11 @@ const routes: RouteRecordRaw[] = [ name: 'terms', component: LegalView, }, + { + path: '/waiting-list/:token', + name: 'waiting-list', + component: WaitingListActionView, + }, // Admin { path: '/admin/subscribers', diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 540062536..20ce5ff32 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,64 +1,96 @@ @@ -81,20 +113,69 @@ const dj = inject('dayjs'); const router = useRouter(); const isPasswordAuth = inject('isPasswordAuth'); const isFxaAuth = inject('isFxaAuth'); +const showInviteFlow = ref(false); +const isLoading = ref(false); // form input and error const username = ref(''); const password = ref(''); const loginError = ref(null); const inviteCode = ref(''); +const showConfirmEmailScreen = ref(false); + +const closeError = () => { + loginError.value = null; +}; + +const goHome = () => { + router.push('/'); +}; + +const signUp = async () => { + isLoading.value = true; + loginError.value = ''; + const { data } = await call('/waiting-list/join').post({ + email: username.value, + }).json(); + + if (!data.value) { + loginError.value = t('waitingList.signUpAlreadyExists'); + } else { + showConfirmEmailScreen.value = true; + } + + isLoading.value = false; +}; -// do log out const login = async () => { if (!username.value || (isPasswordAuth && !password.value)) { loginError.value = t('error.credentialsIncomplete'); return; } + isLoading.value = true; + + // If they come here the first time we check if they're allowed to login + // If they come here a second time after not being allowed it's because they have an invite code. + if (!showInviteFlow.value) { + const { data: canLogin, error } = await call('/can-login').post({ + email: username.value, + }).json(); + + if (error?.value) { + // Bleh + loginError.value = canLogin?.value?.detail[0]?.msg; + isLoading.value = false; + return; + } + + if (!canLogin.value) { + showInviteFlow.value = true; + isLoading.value = false; + return; + } + } + if (isFxaAuth) { const params = new URLSearchParams({ email: username.value, @@ -110,6 +191,7 @@ const login = async () => { if (error.value) { loginError.value = data.value?.detail; + isLoading.value = false; return; } @@ -120,6 +202,7 @@ const login = async () => { const { error } = await user.login(call, username.value, password.value); if (error) { loginError.value = error; + isLoading.value = false; return; } diff --git a/frontend/src/views/WaitingListActionView.vue b/frontend/src/views/WaitingListActionView.vue new file mode 100644 index 000000000..fb260cafd --- /dev/null +++ b/frontend/src/views/WaitingListActionView.vue @@ -0,0 +1,99 @@ + + + diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 46462b926..dde793879 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -358,39 +358,25 @@ resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.1.tgz#361461e5cb3845d874e61731c11cfedd664d83a0" integrity sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA== -"@eslint/config-array@^0.16.0": - version "0.16.0" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.16.0.tgz#bb3364fc39ee84ec3a62abdc4b8d988d99dfd706" - integrity sha512-/jmuSd74i4Czf1XXn7wGRWZCuyaUZ330NH1Bek0Pplatt4Sy1S5haN21SCLLdbeKslQ+S0wEJ+++v5YibSi+Lg== - dependencies: - "@eslint/object-schema" "^2.1.4" - debug "^4.3.1" - minimatch "^3.0.5" - -"@eslint/eslintrc@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.1.0.tgz#dbd3482bfd91efa663cbe7aa1f506839868207b6" - integrity sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ== +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^10.0.1" - globals "^14.0.0" + espree "^9.6.0" + globals "^13.19.0" ignore "^5.2.0" import-fresh "^3.2.1" js-yaml "^4.1.0" minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@9.5.0": - version "9.5.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.5.0.tgz#0e9c24a670b8a5c86bff97b40be13d8d8f238045" - integrity sha512-A7+AOT2ICkodvtsWnxZP4Xxk3NbZ3VMHd8oihydLRGrJgqqdEz1qSeEgXYyT/Cu8h1TWWsQRejIx48mtjZ5y1w== - -"@eslint/object-schema@^2.1.4": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.4.tgz#9e69f8bb4031e11df79e03db09f9dbbae1740843" - integrity sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ== +"@eslint/js@8.57.0": + version "8.57.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" + integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== "@fortawesome/fontawesome-common-types@6.5.2": version "6.5.2" @@ -423,15 +409,24 @@ resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.8.tgz#1e8032df151173d8174ac9f5a28da3c0f5a495e4" integrity sha512-yyHHAj4G8pQIDfaIsMvQpwKMboIZtcHTUvPqXjOHyldh1O1vZfH4W03VDPv5RvI9P6DLTzJQlmVgj9wCf7c2Fw== +"@humanwhocodes/config-array@^0.11.14": + version "0.11.14" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" + integrity sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg== + dependencies: + "@humanwhocodes/object-schema" "^2.0.2" + debug "^4.3.1" + minimatch "^3.0.5" + "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/retry@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.0.tgz#6d86b8cb322660f03d3f0aa94b99bdd8e172d570" - integrity sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew== +"@humanwhocodes/object-schema@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" + integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== "@inquirer/confirm@^3.0.0": version "3.1.11" @@ -946,6 +941,11 @@ resolved "https://registry.yarnpkg.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz#18b97a972f94f60a679fd5c796d96421b9abb9fd" integrity sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g== +"@ungap/structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + "@vitejs/plugin-vue@^5.0.4": version "5.0.5" resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.5.tgz#e3dc11e427d4b818b7e3202766ad156e3d5e2eaa" @@ -1123,7 +1123,7 @@ acorn-walk@^8.3.2: dependencies: acorn "^8.11.0" -acorn@^8.11.0, acorn@^8.11.3, acorn@^8.12.0, acorn@^8.8.1, acorn@^8.9.0: +acorn@^8.11.0, acorn@^8.11.3, acorn@^8.8.1, acorn@^8.9.0: version "8.12.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c" integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw== @@ -1673,6 +1673,13 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + dotenv@^16.3.1: version "16.4.5" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" @@ -1927,7 +1934,7 @@ eslint-scope@5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-scope@^7.1.1: +eslint-scope@^7.1.1, eslint-scope@^7.2.2: version "7.2.2" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== @@ -1935,60 +1942,51 @@ eslint-scope@^7.1.1: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-scope@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.0.1.tgz#a9601e4b81a0b9171657c343fb13111688963cfc" - integrity sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og== - dependencies: - esrecurse "^4.3.0" - estraverse "^5.2.0" - eslint-visitor-keys@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1: +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint-visitor-keys@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz#e3adc021aa038a2a8e0b2f8b0ce8f66b9483b1fb" - integrity sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw== - -eslint@^9.5.0: - version "9.5.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.5.0.tgz#11856034b94a9e1a02cfcc7e96a9f0956963cd2f" - integrity sha512-+NAOZFrW/jFTS3dASCGBxX1pkFD0/fsO+hfAkJ4TyYKwgsXZbqzrw+seCYFCcPCYXvnD67tAnglU7GQTz6kcVw== +eslint@^8.57.0: + version "8.57.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.0.tgz#c786a6fd0e0b68941aaf624596fb987089195668" + integrity sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.6.1" - "@eslint/config-array" "^0.16.0" - "@eslint/eslintrc" "^3.1.0" - "@eslint/js" "9.5.0" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.57.0" + "@humanwhocodes/config-array" "^0.11.14" "@humanwhocodes/module-importer" "^1.0.1" - "@humanwhocodes/retry" "^0.3.0" "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" ajv "^6.12.4" chalk "^4.0.0" cross-spawn "^7.0.2" debug "^4.3.2" + doctrine "^3.0.0" escape-string-regexp "^4.0.0" - eslint-scope "^8.0.1" - eslint-visitor-keys "^4.0.0" - espree "^10.0.1" - esquery "^1.5.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" esutils "^2.0.2" fast-deep-equal "^3.1.3" - file-entry-cache "^8.0.0" + file-entry-cache "^6.0.1" find-up "^5.0.0" glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" ignore "^5.2.0" imurmurhash "^0.1.4" is-glob "^4.0.0" is-path-inside "^3.0.3" + js-yaml "^4.1.0" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" lodash.merge "^4.6.2" @@ -1998,16 +1996,7 @@ eslint@^9.5.0: strip-ansi "^6.0.1" text-table "^0.2.0" -espree@^10.0.1: - version "10.1.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-10.1.0.tgz#8788dae611574c0f070691f522e4116c5a11fc56" - integrity sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA== - dependencies: - acorn "^8.12.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^4.0.0" - -espree@^9.3.1: +espree@^9.3.1, espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== @@ -2016,7 +2005,7 @@ espree@^9.3.1: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.4.1" -esquery@^1.4.0, esquery@^1.5.0: +esquery@^1.4.0, esquery@^1.4.2: version "1.5.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== @@ -2113,12 +2102,12 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -file-entry-cache@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" - integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== dependencies: - flat-cache "^4.0.0" + flat-cache "^3.0.4" fill-range@^7.1.1: version "7.1.1" @@ -2135,13 +2124,14 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -flat-cache@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" - integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== dependencies: flatted "^3.2.9" - keyv "^4.5.4" + keyv "^4.5.3" + rimraf "^3.0.2" flatted@^3.2.9: version "3.3.1" @@ -2280,6 +2270,18 @@ glob@^10.3.10: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^9.3.2: version "9.3.5" resolved "https://registry.yarnpkg.com/glob/-/glob-9.3.5.tgz#ca2ed8ca452781a3009685607fdf025a899dfe21" @@ -2295,18 +2297,13 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.24.0: +globals@^13.19.0, globals@^13.24.0: version "13.24.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== dependencies: type-fest "^0.20.2" -globals@^14.0.0: - version "14.0.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" - integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== - globalthis@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" @@ -2322,6 +2319,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + graphql@^16.8.1: version "16.9.0" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f" @@ -2439,6 +2441,19 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + internal-slot@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" @@ -2701,7 +2716,7 @@ json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -keyv@^4.5.4: +keyv@^4.5.3: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== @@ -2829,7 +2844,7 @@ mini-svg-data-uri@^1.2.3: resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939" integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg== -minimatch@^3.0.5, minimatch@^3.1.2: +minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -3054,6 +3069,13 @@ object.values@^1.1.7: define-properties "^1.2.1" es-object-atoms "^1.0.0" +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + onetime@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4" @@ -3123,6 +3145,11 @@ path-exists@^4.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" @@ -3383,6 +3410,13 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + rollup@^2.77.2: version "2.79.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" @@ -4155,6 +4189,11 @@ wrap-ansi@^8.1.0: string-width "^5.0.1" strip-ansi "^7.0.1" +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + ws@^8.17.0: version "8.17.1" resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"