Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invite panel #400

Merged
merged 19 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -7,10 +7,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 @@ -55,6 +55,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')
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