Skip to content

Commit

Permalink
Invite panel (#400)
Browse files Browse the repository at this point in the history
* Add wip invite panel

* ➕ Add admin subscribers list

* ➕ Add pagination and code revokal for invite codes

* ➕ Add copy button and move table styles

* ➕ Reordered vars and added admin allow list to env

* ➕ Add admin dependency for admin invite routes

* Daisy chain get_admin_subscriber off of get_subscriber, and adjust how we check for admin emails

* Add an invite email and new route that creates a subscriber and emails them!

* Allow a non-allow listed user to login if they already have an account.

* Fix tests

* Added data table

Added sending invitation

Added data table filters

* Fix up fieldClick event, and hide the disable button for now.

* Remove the now unneeded invite panel (sorry buddy, you were a good first draft!)

* Pagination fixes, and send on enter

* Hook up the secret to aws

* Add some more tests

* Add some basic responsiveness

* 💚 Fix alignment, indentation and scrollbar

* 🌐 Update German lang strings

---------

Co-authored-by: Melissa Autumn <[email protected]>
  • Loading branch information
devmount and MelissaAutumn authored May 13, 2024
1 parent 03993e7 commit 93e63ea
Show file tree
Hide file tree
Showing 31 changed files with 988 additions and 93 deletions.
40 changes: 23 additions & 17 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
# Logging level: DEBUG|INFO|WARNING|ERROR|CRITICAL
LOG_LEVEL=ERROR
LOG_USE_STREAM=1
# Possible values: prod, dev
APP_ENV=dev
# List of comma separated admin usernames. USE WITH CAUTION! Those can do serious damage to the data.
APP_ADMIN_ALLOW_LIST=

# -- FRONTEND --
FRONTEND_URL=http://localhost:8080
Expand All @@ -20,12 +24,10 @@ DB_SECRET=
SESSION_SECRET=

# -- MAIL --

# Service email for emails on behalf of Thunderbird Appointment
SERVICE_EMAIL=[email protected]
# Email address for contact or support requests. If the value is empty the support form will error out
SUPPORT_EMAIL=

# Connection security: SSL|STARTTLS|NONE
SMTP_SECURITY=NONE
# Address and port of the SMTP server
Expand All @@ -34,6 +36,8 @@ SMTP_PORT=8050
# SMTP user credentials
SMTP_USER=
SMTP_PASS=
# Authorized email address for sending emails, leave empty to default to organizer
SMTP_SENDER=

# -- TIERS --
# Max number of calendars to be simultanously connected for members of the basic tier
Expand All @@ -43,6 +47,22 @@ TIER_PLUS_CALENDAR_LIMIT=5
# Max number of calendars to be simultanously connected for members of the pro tier
TIER_PRO_CALENDAR_LIMIT=10

# -- GENERAL AUTHENTICATION --
# Possible values: password, fxa
AUTH_SCHEME=password

# For password auth only!
JWT_SECRET=
JWT_ALGO=HS256
JWT_EXPIRE_IN_MINS=10000

# -- FIREFOX AUTH --
FXA_OPEN_ID_CONFIG=
FXA_CLIENT_ID=
FXA_SECRET=
FXA_CALLBACK=
FXA_ALLOW_LIST=

# -- GOOGLE AUTH --
GOOGLE_AUTH_CLIENT_ID=
GOOGLE_AUTH_SECRET=
Expand All @@ -58,23 +78,9 @@ ZOOM_AUTH_CALLBACK=http://localhost:8090/zoom/callback
# -- SIGNED URL SECRET --
# Shared secret for url signing (e.g. create it by running `openssl rand -hex 32`)
SIGNED_SECRET=

# If empty, sentry will be disabled
SENTRY_DSN=
# Possible values: prod, dev
APP_ENV=dev
# Possible values: password, fxa
AUTH_SCHEME=password

FXA_OPEN_ID_CONFIG=
FXA_CLIENT_ID=
FXA_SECRET=
FXA_CALLBACK=
FXA_ALLOW_LIST=

# For password auth only!
JWT_SECRET=
JWT_ALGO=HS256
JWT_EXPIRE_IN_MINS=10000

# -- TESTING --
AUTH0_TEST_USER=
Expand Down
2 changes: 1 addition & 1 deletion backend/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ DB_SECRET=db-secret-pls-ignore
SESSION_SECRET=session-secret-pls-ignore

# -- MAIL --

# Service email for emails on behalf of Thunderbird Appointment
SERVICE_EMAIL=[email protected]
# Email address for contact or support requests
Expand Down Expand Up @@ -64,6 +63,7 @@ SIGNED_SECRET=test-secret-pls-ignore
SENTRY_DSN=
# Possible values: prod, dev, test
APP_ENV=test
# Possible values: password, fxa
AUTH_SCHEME=password

FXA_OPEN_ID_CONFIG=
Expand Down
12 changes: 9 additions & 3 deletions backend/src/appointment/controller/apis/fxa_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,23 @@ def setup(self, subscriber_id=None, token=None):
token=token,
token_updater=self.token_saver)

def is_in_allow_list(self, email: str):
def is_in_allow_list(self, db, email: str):
"""Check this email against our allow list"""

# Allow existing subscribers to login even if they're not on an allow-list
subscriber = repo.subscriber.get_by_email(db, email)
if subscriber:
return True

allow_list = os.getenv('FXA_ALLOW_LIST')
# If we have no allow list, then we allow everyone
if not allow_list or allow_list == '':
return True

return email.endswith(tuple(allow_list.split(',')))

def get_redirect_url(self, state, email):
if not self.is_in_allow_list(email):
def get_redirect_url(self, db, state, email):
if not self.is_in_allow_list(db, email):
raise NotInAllowListException()

utm_campaign = f"{self.ENTRYPOINT}_{os.getenv('APP_ENV')}"
Expand Down
16 changes: 16 additions & 0 deletions backend/src/appointment/controller/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,19 @@ def text(self):

def html(self):
return get_template("support.jinja2").render(requestee=self.requestee, topic=self.topic, details=self.details)


class InviteAccountMail(Mailer):
def __init__(self, *args, **kwargs):
default_kwargs = {
"subject": l10n('new-account-mail-subject')
}
super(InviteAccountMail, self).__init__(*args, **default_kwargs, **kwargs)

def text(self):
return l10n('new-account-mail-plain', {
'homepage_url': os.getenv('FRONTEND_URL'),
})

def html(self):
return get_template("new_account.jinja2").render(homepage_url=os.getenv('FRONTEND_URL'))
6 changes: 3 additions & 3 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean, JSON, Date, Time
from sqlalchemy_utils import StringEncryptedType, ChoiceType, UUIDType
from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine
from sqlalchemy.orm import relationship, as_declarative, declared_attr
from sqlalchemy.orm import relationship, as_declarative, declared_attr, Mapped
from sqlalchemy.sql import func


Expand Down Expand Up @@ -116,7 +116,7 @@ class Subscriber(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: "Invite" = relationship("Invite", back_populates="subscriber")
invite: Mapped["Invite"] = relationship("Invite", 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"""
Expand Down Expand Up @@ -288,7 +288,7 @@ class Invite(Base):
code = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False)
status = Column(Enum(InviteStatus), index=True)

subscriber = relationship("Subscriber", back_populates="invite")
subscriber: Mapped["Subscriber"] = relationship("Subscriber", back_populates="invite", single_parent=True)

@property
def is_used(self) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion backend/src/appointment/database/repo/subscriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def get(db: Session, subscriber_id: int) -> models.Subscriber | None:
return db.get(models.Subscriber, subscriber_id)


def get_by_email(db: Session, email: str):
def get_by_email(db: Session, email: str) -> models.Subscriber | None:
"""retrieve subscriber by email"""
return db.query(models.Subscriber).filter(models.Subscriber.email == email).first()

Expand Down
34 changes: 25 additions & 9 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,19 @@ class CalendarOut(CalendarBase):
id: int


""" INVITE model schemas
"""


class Invite(BaseModel):
subscriber_id: int | None = None
code: str
status: InviteStatus = InviteStatus.active
time_created: datetime | None = None
time_updated: datetime | None = None



""" SUBSCRIBER model schemas
"""

Expand Down Expand Up @@ -253,16 +266,12 @@ class Config:
from_attributes = True


""" INVITE model schemas
"""

class SubscriberAdminOut(Subscriber):
invite: Invite | None = None
time_created: datetime

class Invite(BaseModel):
subscriber_id: int | None = None
code: str
status: InviteStatus = InviteStatus.active
time_created: datetime | None = None
time_updated: datetime | None = None
class Config:
from_attributes = True


""" other schemas used for requests or data migration
Expand Down Expand Up @@ -357,3 +366,10 @@ class Login(BaseModel):

class TokenData(BaseModel):
username: str


"""Invite"""


class SendInviteEmailIn(BaseModel):
email: str = Field(title="Email", min_length=1)
22 changes: 20 additions & 2 deletions backend/src/appointment/dependencies/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@

from sqlalchemy.orm import Session

from ..database import repo, schemas
from ..database import repo, schemas, models
from ..dependencies.database import get_db
from ..exceptions import validation
from ..exceptions.validation import InvalidTokenException
from ..exceptions.validation import InvalidTokenException, InvalidPermissionLevelException

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)

Expand Down Expand Up @@ -62,6 +62,24 @@ def get_subscriber(
return user


def get_admin_subscriber(
user: models.Subscriber = Depends(get_subscriber),
):
"""Retrieve the subscriber and check if they're an admin"""
# check admin allow list
admin_emails = os.getenv("APP_ADMIN_ALLOW_LIST")

# Raise an error if we don't have any admin emails specified
if not admin_emails:
raise InvalidPermissionLevelException()

admin_emails = admin_emails.split(',')
if not any([user.email.endswith(allowed_email) for allowed_email in admin_emails]):
raise InvalidPermissionLevelException()

return user


def get_subscriber_from_signed_url(
url: str = Body(..., embed=True),
db: Session = Depends(get_db),
Expand Down
30 changes: 28 additions & 2 deletions backend/src/appointment/exceptions/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ def get_msg(self):
return l10n('unknown-error')


class InvalidPermissionLevelException(APIException):
"""Raise when the subscribers permission level is too low for the action"""
id_code = 'INVALID_PERMISSION_LEVEL'
status_code = 401

def get_msg(self):
return l10n('protected-route-fail')


class InvalidTokenException(APIException):
"""Raise when the subscriber could not be parsed from the auth token"""
id_code = 'INVALID_TOKEN'
Expand All @@ -39,14 +48,13 @@ def get_msg(self):


class SubscriberNotFoundException(APIException):
"""Raise when the calendar is not found during route validation"""
"""Raise when the subscriber is not found during route validation"""
id_code = 'SUBSCRIBER_NOT_FOUND'
status_code = 404

def get_msg(self):
return l10n('subscriber-not-found')


class CalendarNotFoundException(APIException):
"""Raise when the calendar is not found during route validation"""
id_code = 'CALENDAR_NOT_FOUND'
Expand Down Expand Up @@ -187,3 +195,21 @@ class InviteCodeNotAvailableException(APIException):

def get_msg(self):
return l10n('invite-code-not-valid')


class CreateSubscriberFailedException(APIException):
"""Raise when a subscriber failed to be created"""
id_code = 'CREATE_SUBSCRIBER_FAILED'
status_code = 400

def get_msg(self):
return l10n('failed-to-create-subscriber')


class CreateSubscriberAlreadyExistsException(APIException):
"""Raise when a subscriber failed to be created"""
id_code = 'CREATE_SUBSCRIBER_ALREADY_EXISTS'
status_code = 400

def get_msg(self):
return l10n('subscriber-already-exists')
3 changes: 3 additions & 0 deletions backend/src/appointment/l10n/de/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ remote-calendar-connection-error = Der angebundene Kalender konnte nicht erreich
event-could-not-be-accepted = Es ist ein Fehler bei der Annahme der Buchungsdaten aufgetreten. Bitte später noch einmal versuchen.
failed-to-create-subscriber = Es gab einen Fehler beim Anlegen der Person. Bitte später erneut versuchen.
subscriber-already-exists = Eine Person mit dieser E-Mail-Adresse existiert bereits.
## Authentication Exceptions

email-mismatch = E-Mail-Adresse stimmen nicht überein.
Expand Down
11 changes: 11 additions & 0 deletions backend/src/appointment/l10n/en/email.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,14 @@ support-mail-plain = { $requestee_name } ({ $requestee_email }) sent the followi
Topic: { $topic }
Details: { $details }
{-brand-footer}
## New/Invited Account Email
new-account-mail-subject = You've been invited to Thunderbird Appointment
new-account-mail-action = Continue to Thunderbird Appointment
new-account-mail-html-heading = You've been invited to Thunderbird Appointment. Login with this email address to continue.
# Variables:
# $homepage_url (String) - URL to Thunderbird Appointment
new-account-mail-plain = You've been invited to Thunderbird Appointment.
Login with this email address to continue.
{ $homepage_url }
{-brand-footer}
3 changes: 3 additions & 0 deletions backend/src/appointment/l10n/en/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ remote-calendar-connection-error = The remote calendar could not be reached. Ple
event-could-not-be-accepted = There was an error accepting the booking details. Please try again later.
failed-to-create-subscriber = There was an error creating the subscriber. Please try again later.
subscriber-already-exists = A subscriber with this email address already exists.
## Authentication Exceptions

email-mismatch = Email mismatch.
Expand Down
2 changes: 2 additions & 0 deletions backend/src/appointment/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def server():
from .routes import google
from .routes import schedule
from .routes import invite
from .routes import subscriber
from .routes import zoom
from .routes import webhooks

Expand Down Expand Up @@ -189,6 +190,7 @@ async def catch_google_refresh_errors(request, exc):
app.include_router(google.router, prefix="/google")
app.include_router(schedule.router, prefix="/schedule")
app.include_router(invite.router, prefix="/invite")
app.include_router(subscriber.router, prefix="/subscriber")
app.include_router(webhooks.router, prefix="/webhooks")
if os.getenv("ZOOM_API_ENABLED"):
app.include_router(zoom.router, prefix="/zoom")
Expand Down
Loading

0 comments on commit 93e63ea

Please sign in to comment.