diff --git a/api/app/config.py b/api/app/config.py index b9d8c4a..9c0ed6d 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -23,6 +23,37 @@ MY_SOLID_APP_REDIS_HOST = os.environ.get("MY_SOLID_APP_REDIS_HOST", "localhost") +# MinIO object storage +MY_SOLID_APP_MINIO_HOST = os.environ.get("MY_SOLID_APP_MINIO_HOST", "localhost") +_minio_port_raw = os.environ.get("MY_SOLID_APP_MINIO_PORT", "9000") +MY_SOLID_APP_MINIO_PORT: int | None = int(_minio_port_raw) if _minio_port_raw else None +"""MinIO/S3 port. Leave empty for standard HTTPS (TransIP Object Storage, etc.).""" +MY_SOLID_APP_MINIO_USER = os.environ.get("MY_SOLID_APP_MINIO_USER", "minioadmin") +MY_SOLID_APP_MINIO_PASSWORD = os.environ.get("MY_SOLID_APP_MINIO_PASSWORD", "minioadmin") +MY_SOLID_APP_MINIO_BUCKET = os.environ.get( + "MY_SOLID_APP_MINIO_BUCKET", "my-solid-app-dev" +) +MY_SOLID_APP_MINIO_SECURE = ( + os.environ.get("MY_SOLID_APP_MINIO_SECURE", "False") == "True" +) +"""Whether to use HTTPS for MinIO connections. Set to True in production.""" +MY_SOLID_APP_MINIO_REGION = os.environ.get("MY_SOLID_APP_MINIO_REGION", "EU") + +MY_SOLID_APP_STORAGE_MAX_FILE_BYTES = int( + os.environ.get("MY_SOLID_APP_STORAGE_MAX_FILE_BYTES", str(200 * 1024 * 1024)) +) +"""Maximum upload size per file in bytes. Defaults to 200 MB.""" + +MY_SOLID_APP_STORAGE_DEFAULT_QUOTA_BYTES = int( + os.environ.get( + "MY_SOLID_APP_STORAGE_DEFAULT_QUOTA_BYTES", str(10 * 1024 * 1024 * 1024) + ) +) +"""Default storage quota per workspace in bytes. Defaults to 10 GB.""" + +MY_SOLID_APP_MOLLIE_API_KEY = os.environ.get("MY_SOLID_APP_MOLLIE_API_KEY", "test_xxxxx") +MY_SOLID_APP_API_URL = os.environ.get("MY_SOLID_APP_API_URL", "http://localhost:5000") + class BaseConfig: SECRET_KEY = os.environ.get("MY_SOLID_APP_SECRET_KEY", "secret_oohhhhhh") @@ -43,6 +74,17 @@ class BaseConfig: ) FILE_LOGGING = os.environ.get("MY_SOLID_APP_FILE_LOGGING", "False") == "True" + # MinIO + MINIO_HOST = MY_SOLID_APP_MINIO_HOST + MINIO_PORT = MY_SOLID_APP_MINIO_PORT + MINIO_USER = MY_SOLID_APP_MINIO_USER + MINIO_PASSWORD = MY_SOLID_APP_MINIO_PASSWORD + MINIO_BUCKET = MY_SOLID_APP_MINIO_BUCKET + MINIO_SECURE = MY_SOLID_APP_MINIO_SECURE + MINIO_REGION = MY_SOLID_APP_MINIO_REGION + STORAGE_MAX_FILE_BYTES = MY_SOLID_APP_STORAGE_MAX_FILE_BYTES + STORAGE_DEFAULT_QUOTA_BYTES = MY_SOLID_APP_STORAGE_DEFAULT_QUOTA_BYTES + CELERY = { "broker_url": f"redis://{MY_SOLID_APP_REDIS_HOST}", "result_backend": f"redis://{MY_SOLID_APP_REDIS_HOST}", @@ -53,6 +95,9 @@ class BaseConfig: class ProdConfig(BaseConfig): ENV = "prod" DEBUG = False + SESSION_COOKIE_SAMESITE = "None" + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_HTTPONLY = True class DevConfig(BaseConfig): diff --git a/api/app/db/__init__.py b/api/app/db/__init__.py index 420f444..aba0b2a 100644 --- a/api/app/db/__init__.py +++ b/api/app/db/__init__.py @@ -1 +1,3 @@ +from app.db.billing import Invoice, WorkspaceSubscription from app.db.user import User +from app.db.workspace import Workspace, WorkspaceInvitation, WorkspaceMember diff --git a/api/app/db/billing.py b/api/app/db/billing.py new file mode 100644 index 0000000..b656ed3 --- /dev/null +++ b/api/app/db/billing.py @@ -0,0 +1,154 @@ +from datetime import datetime, timezone +from decimal import Decimal + +from marshmallow import Schema, fields +from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.fields import UTCDateTime +from app.extensions import db + + +class SubscriptionPlan: + FREE_TRIAL = "free_trial" + PAID = "paid" + + +class SubscriptionStatus: + ACTIVE = "active" + PENDING = "pending" + PAST_DUE = "past_due" + CANCELLED = "cancelled" + + +FREE_TRIAL_DAYS = 30 +SEAT_PRICE_EUR = Decimal("9.99") +VAT_RATE = Decimal("0.21") + + +class WorkspaceSubscription(db.Model): + __tablename__ = "workspace_subscription" + + id: Mapped[int] = mapped_column(primary_key=True) + workspace_id: Mapped[int] = mapped_column( + ForeignKey("workspace.id", ondelete="CASCADE"), unique=True + ) + plan: Mapped[str] = mapped_column( + String(20), default=SubscriptionPlan.FREE_TRIAL, nullable=False + ) + status: Mapped[str] = mapped_column( + String(20), default=SubscriptionStatus.ACTIVE, nullable=False + ) + mollie_customer_id: Mapped[str | None] = mapped_column(String(50), nullable=True) + mollie_subscription_id: Mapped[str | None] = mapped_column(String(50), nullable=True) + seats: Mapped[int] = mapped_column(default=1, nullable=False) + billing_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + billing_exempt: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + billing_interval: Mapped[str | None] = mapped_column(String(20), nullable=True) + billing_price_override: Mapped[Decimal | None] = mapped_column( + Numeric(10, 2), nullable=True + ) + paid_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=func.now(), onupdate=func.now() + ) + + workspace: Mapped["db.Model"] = relationship("Workspace") + invoices: Mapped[list["Invoice"]] = relationship( + "Invoice", back_populates="subscription", cascade="all, delete-orphan" + ) + + @property + def is_paid(self) -> bool: + if self.billing_exempt: + return True + if ( + self.plan == SubscriptionPlan.PAID + and self.status == SubscriptionStatus.ACTIVE + ): + return True + if ( + self.plan == SubscriptionPlan.PAID + and self.status == SubscriptionStatus.CANCELLED + and self.paid_until is not None + ): + paid_until = self.paid_until + if paid_until.tzinfo is None: + paid_until = paid_until.replace(tzinfo=timezone.utc) + return datetime.now(timezone.utc) < paid_until + return False + + @property + def seat_price(self) -> Decimal: + if self.billing_price_override is not None: + return self.billing_price_override + return SEAT_PRICE_EUR + + @property + def monthly_amount(self) -> Decimal: + if self.plan == SubscriptionPlan.PAID: + return self.seat_price * self.seats + return Decimal("0.00") + + +class InvoiceStatus: + PAID = "paid" + PENDING = "pending" + FAILED = "failed" + + +class Invoice(db.Model): + __tablename__ = "invoice" + + id: Mapped[int] = mapped_column(primary_key=True) + workspace_id: Mapped[int] = mapped_column( + ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False + ) + subscription_id: Mapped[int] = mapped_column( + ForeignKey("workspace_subscription.id", ondelete="CASCADE"), nullable=False + ) + mollie_payment_id: Mapped[str] = mapped_column( + String(50), unique=True, nullable=False + ) + amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="EUR", nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False) + description: Mapped[str] = mapped_column(String(255), nullable=False) + seats: Mapped[int] = mapped_column(default=1, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) + + subscription: Mapped["WorkspaceSubscription"] = relationship( + "WorkspaceSubscription", back_populates="invoices" + ) + + +class WorkspaceSubscriptionSchema(Schema): + id = fields.Integer() + workspace_id = fields.Integer() + plan = fields.String() + status = fields.String() + seats = fields.Integer() + billing_email = fields.String(allow_none=True) + billing_exempt = fields.Boolean() + billing_interval = fields.String(allow_none=True) + billing_price_override = fields.Decimal(as_string=True, allow_none=True) + monthly_amount = fields.Method("get_monthly_amount") + created_at = UTCDateTime() + updated_at = UTCDateTime() + + def get_monthly_amount(self, obj: WorkspaceSubscription) -> str: + return str(obj.monthly_amount) + + +class InvoiceSchema(Schema): + id = fields.Integer() + workspace_id = fields.Integer() + subscription_id = fields.Integer() + mollie_payment_id = fields.String() + amount = fields.Decimal(as_string=True) + currency = fields.String() + status = fields.String() + description = fields.String() + seats = fields.Integer() + created_at = UTCDateTime() diff --git a/api/app/db/fields.py b/api/app/db/fields.py new file mode 100644 index 0000000..7b34cf0 --- /dev/null +++ b/api/app/db/fields.py @@ -0,0 +1,17 @@ +from marshmallow import fields + + +class UTCDateTime(fields.DateTime): + """Serialize naive datetimes as UTC ISO 8601 strings (with 'Z' suffix). + + The database stores naive UTC datetimes. Without a timezone indicator, + JavaScript's Date constructor treats them as local time, causing the + computed diff to be off by the user's UTC offset. Appending 'Z' ensures + JS always parses them as UTC. + """ + + def _serialize(self, value, attr, obj, **kwargs): + result = super()._serialize(value, attr, obj, **kwargs) + if result is not None and not result.endswith("Z") and "+" not in result: + result += "Z" + return result diff --git a/api/app/db/user.py b/api/app/db/user.py index 37ec4c1..23cafde 100644 --- a/api/app/db/user.py +++ b/api/app/db/user.py @@ -17,6 +17,7 @@ class User(db.Model, UserMixin): id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String(100), unique=True, index=True) + name: Mapped[str] = mapped_column(String(100), default="") is_admin: Mapped[bool] = mapped_column(default=False) hashed_password: Mapped[str] = mapped_column(String(256)) @@ -86,6 +87,7 @@ def totp_secret(self, totp_secret: str | None): class UserSchema(Schema): id = fields.Integer() email = fields.String(validate=validate.Length(max=100)) + name = fields.String(validate=validate.Length(max=100)) is_admin = fields.Boolean() is_verified = fields.Boolean(dump_only=True) two_factor_enabled = fields.Boolean(dump_only=True) diff --git a/api/app/db/workspace.py b/api/app/db/workspace.py new file mode 100644 index 0000000..24c7f37 --- /dev/null +++ b/api/app/db/workspace.py @@ -0,0 +1,130 @@ +import secrets +from datetime import datetime + +from marshmallow import Schema, fields +from sqlalchemy import Boolean, DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from werkzeug.security import check_password_hash, generate_password_hash + +from app.db.fields import UTCDateTime +from app.extensions import db + + +class WorkspaceMemberRole: + OWNER = "owner" + ADMIN = "admin" + MEMBER = "member" + + +WORKSPACE_LANGUAGES = ("nl", "en") +DEFAULT_WORKSPACE_LANGUAGE = "nl" + + +class Workspace(db.Model): + __tablename__ = "workspace" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + created_by: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True + ) + setup_completed: Mapped[bool] = mapped_column(Boolean, default=False) + color: Mapped[str | None] = mapped_column(String(7), nullable=True) + context: Mapped[str | None] = mapped_column(String(2000), nullable=True) + language: Mapped[str] = mapped_column( + String(5), default=DEFAULT_WORKSPACE_LANGUAGE, server_default="nl" + ) + created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) + + members: Mapped[list["WorkspaceMember"]] = relationship( + "WorkspaceMember", back_populates="workspace", cascade="all, delete-orphan" + ) + invitations: Mapped[list["WorkspaceInvitation"]] = relationship( + "WorkspaceInvitation", back_populates="workspace", cascade="all, delete-orphan" + ) + + +class WorkspaceMember(db.Model): + __tablename__ = "workspace_member" + + id: Mapped[int] = mapped_column(primary_key=True) + workspace_id: Mapped[int] = mapped_column(ForeignKey("workspace.id")) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE")) + role: Mapped[str] = mapped_column(String(20), default=WorkspaceMemberRole.MEMBER) + joined_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) + + workspace: Mapped["Workspace"] = relationship("Workspace", back_populates="members") + user: Mapped["db.Model"] = relationship("User") + + +class WorkspaceInvitation(db.Model): + __tablename__ = "workspace_invitation" + + id: Mapped[int] = mapped_column(primary_key=True) + workspace_id: Mapped[int] = mapped_column(ForeignKey("workspace.id")) + email: Mapped[str] = mapped_column(String(100), index=True) + hashed_token: Mapped[str] = mapped_column(String(256)) + invited_by: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) + accepted: Mapped[bool] = mapped_column(default=False) + + workspace: Mapped["Workspace"] = relationship( + "Workspace", back_populates="invitations" + ) + inviter: Mapped["db.Model | None"] = relationship("User", foreign_keys=[invited_by]) + + def set_token(self) -> str: + token = secrets.token_urlsafe(32) + self.hashed_token = generate_password_hash(token) + return token + + def check_token(self, token: str) -> bool: + return check_password_hash(self.hashed_token, token) + + +class WorkspaceMemberSchema(Schema): + id = fields.Integer() + workspace_id = fields.Integer() + user_id = fields.Integer() + role = fields.String() + joined_at = UTCDateTime() + email = fields.Method("get_email") + name = fields.Method("get_name") + + def get_email(self, obj: WorkspaceMember) -> str: + return obj.user.email if obj.user else "" + + def get_name(self, obj: WorkspaceMember) -> str: + return obj.user.name if obj.user else "" + + +class WorkspaceSchema(Schema): + id = fields.Integer() + name = fields.String() + color = fields.String(allow_none=True) + context = fields.String(allow_none=True) + language = fields.String() + setup_completed = fields.Boolean() + created_by = fields.Integer(allow_none=True) + created_at = UTCDateTime() + members = fields.List(fields.Nested(WorkspaceMemberSchema)) + + +class WorkspaceListItemSchema(Schema): + id = fields.Integer() + name = fields.String() + color = fields.String(allow_none=True) + setup_completed = fields.Boolean() + role = fields.String() + member_count = fields.Integer() + + +class WorkspaceInvitationSchema(Schema): + id = fields.Integer() + workspace_id = fields.Integer() + email = fields.String() + invited_by = fields.Integer() + created_at = UTCDateTime() + accepted = fields.Boolean() diff --git a/api/app/errors.py b/api/app/errors.py index 727425a..55eece8 100644 --- a/api/app/errors.py +++ b/api/app/errors.py @@ -16,6 +16,19 @@ class APIErrorEnum(IntEnum): already_2fa_disabled = 11 user_not_found = 12 unknown_error = 13 + workspace_not_found = 14 + not_workspace_member = 15 + not_workspace_owner_or_admin = 16 + already_workspace_member = 17 + invitation_not_found = 18 + cannot_remove_owner = 19 + subscription_not_found = 26 + billing_error = 27 + invoice_not_found = 28 + seat_limit_reached = 29 + cannot_leave_last_owner = 30 + workspace_frozen = 33 + upgrade_disabled = 35 class APIError(Exception): diff --git a/api/app/resources/__init__.py b/api/app/resources/__init__.py index 9380b00..2aa78b9 100644 --- a/api/app/resources/__init__.py +++ b/api/app/resources/__init__.py @@ -1,3 +1,5 @@ from app.resources.authentication import * +from app.resources.billing import * from app.resources.two_factor import * from app.resources.user import * +from app.resources.workspace import * diff --git a/api/app/resources/authentication.py b/api/app/resources/authentication.py index b13793f..aa2a21e 100644 --- a/api/app/resources/authentication.py +++ b/api/app/resources/authentication.py @@ -23,6 +23,7 @@ def load_user(user_id): class RegisterSchema(Schema): + name = fields.String(load_default="") email = fields.String(required=True) password = fields.String(required=True) @@ -39,7 +40,7 @@ def post(self): 409, ) - new_user = User(email=data.get("email")) + new_user = User(email=data.get("email"), name=data.get("name", "")) new_user.set_password(data.get("password")) db.session.add(new_user) diff --git a/api/app/resources/billing.py b/api/app/resources/billing.py new file mode 100644 index 0000000..022016d --- /dev/null +++ b/api/app/resources/billing.py @@ -0,0 +1,1114 @@ +import io +import os +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal + +from flask import current_app, request, send_file +from flask_login import current_user, login_required +from flask_restx import Resource +from fpdf import FPDF, XPos, YPos + +from app.config import ( + MY_SOLID_APP_API_URL, + MY_SOLID_APP_FRONTEND_URL, + MY_SOLID_APP_MOLLIE_API_KEY, +) +from app.db.billing import ( + FREE_TRIAL_DAYS, + VAT_RATE, + Invoice, + InvoiceSchema, + InvoiceStatus, + SubscriptionPlan, + SubscriptionStatus, + WorkspaceSubscription, + WorkspaceSubscriptionSchema, +) +from app.db.workspace import Workspace, WorkspaceMember, WorkspaceMemberRole +from app.errors import APIError, APIErrorEnum +from app.extensions import api, db + +_LOGO_PATH = os.path.join(os.path.dirname(__file__), "..", "static", "logo.png") + + +def sync_subscription_seats(workspace_id: int) -> None: + """Automatically sync subscription seats to match actual member count.""" + sub = WorkspaceSubscription.query.filter_by(workspace_id=workspace_id).first() + if sub is None: + return + if sub.billing_exempt: + return + if sub.plan != SubscriptionPlan.PAID or sub.status != SubscriptionStatus.ACTIVE: + return + + member_count = WorkspaceMember.query.filter_by(workspace_id=workspace_id).count() + new_seats = max(member_count, 1) + + if new_seats == sub.seats: + return + + old_seats = sub.seats + sub.seats = new_seats + + if sub.mollie_customer_id and sub.mollie_subscription_id: + try: + mollie = _get_mollie_client() + amount = sub.seat_price * new_seats + customer = mollie.customers.get(sub.mollie_customer_id) + customer.subscriptions.update( + sub.mollie_subscription_id, + { + "amount": {"currency": "EUR", "value": f"{amount:.2f}"}, + "description": f"Subscription - {new_seats} seat{'s' if new_seats != 1 else ''}", + "metadata": { + "workspace_id": str(workspace_id), + "seats": str(new_seats), + }, + }, + ) + except Exception as e: + current_app.logger.error( + "Failed to update Mollie subscription for workspace %d: %s", + workspace_id, + str(e), + ) + sub.seats = old_seats + db.session.commit() + return + + db.session.commit() + + +def _get_mollie_client(): + from mollie.api.client import Client + + client = Client() + client.set_api_key(MY_SOLID_APP_MOLLIE_API_KEY) + return client + + +def _get_workspace_and_assert_owner_or_admin( + workspace_id: int, +) -> tuple[Workspace, WorkspaceMember]: + workspace = Workspace.query.get(workspace_id) + if workspace is None: + raise APIError(APIErrorEnum.workspace_not_found, "Workspace not found", 404) + + member = WorkspaceMember.query.filter_by( + workspace_id=workspace_id, user_id=current_user.id + ).first() + if member is None: + raise APIError( + APIErrorEnum.not_workspace_member, "Not a member of this workspace", 403 + ) + if member.role not in (WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN): + raise APIError( + APIErrorEnum.not_workspace_owner_or_admin, + "This action requires owner or admin role", + 403, + ) + return workspace, member + + +def get_or_create_subscription(workspace_id: int) -> WorkspaceSubscription: + sub = WorkspaceSubscription.query.filter_by(workspace_id=workspace_id).first() + if sub is None: + sub = WorkspaceSubscription( + workspace_id=workspace_id, + plan=SubscriptionPlan.FREE_TRIAL, + status=SubscriptionStatus.ACTIVE, + seats=1, + ) + db.session.add(sub) + db.session.commit() + return sub + + +def _resolve_stale_pending(sub: WorkspaceSubscription): + """Check whether a PENDING subscription's Mollie payment is still open.""" + latest_invoice = ( + Invoice.query.filter_by(subscription_id=sub.id, status=InvoiceStatus.PENDING) + .order_by(Invoice.created_at.desc()) + .first() + ) + if latest_invoice is None: + sub.status = SubscriptionStatus.ACTIVE + sub.plan = SubscriptionPlan.FREE_TRIAL + db.session.commit() + return + + try: + mollie = _get_mollie_client() + payment = mollie.payments.get(latest_invoice.mollie_payment_id) + mollie_status = payment["status"] + except Exception: + return + + if mollie_status in ("expired", "canceled", "failed"): + latest_invoice.status = InvoiceStatus.FAILED + sub.status = SubscriptionStatus.ACTIVE + sub.plan = SubscriptionPlan.FREE_TRIAL + db.session.commit() + + +def get_workspace_frozen_state( + sub: WorkspaceSubscription, +) -> tuple[bool, str | None]: + """Return (is_frozen, frozen_reason) for a workspace subscription.""" + if sub.billing_exempt: + return False, None + + if sub.plan == SubscriptionPlan.PAID and sub.status == SubscriptionStatus.ACTIVE: + return False, None + + if sub.plan == SubscriptionPlan.PAID and sub.status == SubscriptionStatus.CANCELLED: + if sub.paid_until is not None: + paid_until = sub.paid_until + if paid_until.tzinfo is None: + paid_until = paid_until.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) < paid_until: + return False, None + return True, "subscription_expired" + + if sub.plan == SubscriptionPlan.FREE_TRIAL: + created = sub.created_at + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + elapsed = (datetime.now(timezone.utc) - created).days + if elapsed >= FREE_TRIAL_DAYS: + return True, "trial_expired" + return False, None + + return False, None + + +def get_trial_days_remaining(sub: WorkspaceSubscription) -> int: + """Return number of free trial days remaining (0 if expired, -1 if paid).""" + if sub.billing_exempt: + return -1 + if sub.is_paid: + return -1 + if sub.plan != SubscriptionPlan.FREE_TRIAL: + return 0 + created = sub.created_at + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + elapsed = (datetime.now(timezone.utc) - created).days + return max(0, FREE_TRIAL_DAYS - elapsed) + + +def assert_workspace_not_frozen(workspace_id: int) -> None: + """Raise APIError if the workspace is frozen (trial expired or subscription ended).""" + sub = WorkspaceSubscription.query.filter_by(workspace_id=workspace_id).first() + if sub is None: + return + is_frozen, reason = get_workspace_frozen_state(sub) + if is_frozen: + raise APIError( + APIErrorEnum.workspace_frozen, + "This workspace is frozen. Please upgrade to continue.", + 403, + ) + + +def _latin1(text: str) -> str: + """Replace characters outside latin-1 with closest ASCII equivalents.""" + replacements = { + "–": "-", + "—": "-", + "‘": "'", + "’": "'", + "“": '"', + "”": '"', + "…": "...", + } + for char, sub in replacements.items(): + text = text.replace(char, sub) + return text.encode("latin-1", errors="replace").decode("latin-1") + + +def _generate_invoice_pdf(invoice: Invoice, workspace_name: str) -> bytes: + pdf = FPDF() + pdf.set_auto_page_break(auto=False) + pdf.add_page() + + page_w = pdf.w + margin = 20 + content_w = page_w - 2 * margin + + green = (28, 184, 126) + dark = (26, 26, 46) + gray = (107, 114, 128) + light_gray = (156, 163, 175) + white = (255, 255, 255) + bg_light = (249, 250, 251) + border_color = (229, 231, 235) + green_bg = (240, 253, 248) + green_border = (214, 245, 233) + + pdf.set_fill_color(*green) + pdf.rect(0, 0, page_w, 4, "F") + + header_y = 16 + pdf.set_y(header_y) + + if os.path.exists(_LOGO_PATH): + pdf.image(_LOGO_PATH, x=margin, y=header_y, w=14, h=14) + + pdf.set_xy(margin + 17, header_y) + pdf.set_font("Helvetica", "B", 18) + pdf.set_text_color(*dark) + pdf.cell(pdf.get_string_width("My"), 14, "My", new_x=XPos.RIGHT, new_y=YPos.TOP) + pdf.set_text_color(*green) + pdf.cell(pdf.get_string_width("App"), 14, "App", new_x=XPos.RIGHT, new_y=YPos.TOP) + + pdf.set_font("Helvetica", "B", 28) + pdf.set_text_color(*dark) + pdf.set_xy(page_w - margin - 60, header_y - 2) + pdf.cell(60, 16, "INVOICE", align="R", new_x=XPos.LMARGIN, new_y=YPos.NEXT) + + pdf.set_font("Helvetica", "", 10) + pdf.set_text_color(*gray) + pdf.set_xy(page_w - margin - 60, header_y + 16) + pdf.cell( + 60, + 6, + f"#{str(invoice.id).zfill(6)}", + align="R", + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + + pdf.ln(10) + y_div = pdf.get_y() + pdf.set_draw_color(*border_color) + pdf.set_line_width(0.3) + pdf.line(margin, y_div, page_w - margin, y_div) + pdf.ln(10) + + meta_y = pdf.get_y() + col_w = content_w / 2 + + pdf.set_xy(margin, meta_y) + pdf.set_font("Helvetica", "B", 8) + pdf.set_text_color(*light_gray) + pdf.cell(col_w, 5, "INVOICE DETAILS", new_x=XPos.LMARGIN, new_y=YPos.NEXT) + + pdf.set_xy(margin, meta_y + 8) + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*gray) + pdf.cell(28, 6, "Date:") + pdf.set_text_color(*dark) + pdf.set_font("Helvetica", "B", 9) + pdf.cell( + col_w - 28, + 6, + invoice.created_at.strftime("%B %d, %Y"), + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + + pdf.set_xy(margin, meta_y + 16) + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*gray) + pdf.cell(28, 6, "Payment:") + pdf.set_text_color(*dark) + pdf.set_font("Helvetica", "", 9) + pdf.cell( + col_w - 28, + 6, + invoice.mollie_payment_id, + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + + pdf.set_xy(margin, meta_y + 26) + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*gray) + pdf.cell(28, 6, "Status:") + if invoice.status == InvoiceStatus.PAID: + pdf.set_fill_color(*green_bg) + pdf.set_draw_color(*green_border) + pdf.set_text_color(*green) + pdf.set_font("Helvetica", "B", 8) + badge_x = margin + 28 + badge_y = meta_y + 26.5 + pdf.rect(badge_x, badge_y, 20, 5, "DF") + pdf.set_xy(badge_x, badge_y) + pdf.cell(20, 5, "PAID", align="C", new_x=XPos.LMARGIN, new_y=YPos.NEXT) + else: + pdf.set_text_color(220, 38, 38) + pdf.set_font("Helvetica", "B", 8) + pdf.cell( + 20, + 5, + invoice.status.upper(), + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + + pdf.set_xy(margin + col_w, meta_y) + pdf.set_font("Helvetica", "B", 8) + pdf.set_text_color(*light_gray) + pdf.cell(col_w, 5, "BILL TO", new_x=XPos.LMARGIN, new_y=YPos.NEXT) + + pdf.set_xy(margin + col_w, meta_y + 8) + pdf.set_font("Helvetica", "B", 10) + pdf.set_text_color(*dark) + pdf.cell(col_w, 6, _latin1(workspace_name), new_x=XPos.LMARGIN, new_y=YPos.NEXT) + + pdf.set_y(meta_y + 42) + pdf.ln(6) + table_y = pdf.get_y() + + hdr_h = 10 + pdf.set_fill_color(*dark) + pdf.rect(margin, table_y, content_w, hdr_h, "F") + + pdf.set_xy(margin, table_y) + pdf.set_font("Helvetica", "B", 8.5) + pdf.set_text_color(*white) + + col_desc = content_w * 0.55 + col_seats = content_w * 0.15 + col_unit = content_w * 0.15 + col_amount = content_w * 0.15 + + pdf.cell(col_desc, hdr_h, " Description") + pdf.cell(col_seats, hdr_h, "Seats", align="C") + pdf.cell(col_unit, hdr_h, "Unit Price", align="R") + pdf.cell( + col_amount, hdr_h, "Amount ", align="R", new_x=XPos.LMARGIN, new_y=YPos.NEXT + ) + + row_y = table_y + hdr_h + row_h = 11 + pdf.set_fill_color(*bg_light) + pdf.rect(margin, row_y, content_w, row_h, "F") + + pdf.set_draw_color(*border_color) + pdf.set_line_width(0.2) + pdf.line(margin, row_y + row_h, page_w - margin, row_y + row_h) + + pdf.set_xy(margin, row_y) + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*dark) + + unit_price = ( + (invoice.amount / invoice.seats) if invoice.seats > 0 else invoice.amount + ) + + pdf.cell(col_desc, row_h, f" {_latin1(invoice.description)}") + pdf.cell(col_seats, row_h, str(invoice.seats), align="C") + pdf.set_text_color(*gray) + pdf.cell(col_unit, row_h, f"{invoice.currency} {unit_price:.2f}", align="R") + pdf.set_text_color(*dark) + pdf.set_font("Helvetica", "B", 9) + pdf.cell( + col_amount, + row_h, + f"{invoice.currency} {invoice.amount:.2f} ", + align="R", + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + + pdf.ln(8) + totals_y = pdf.get_y() + totals_x = page_w - margin - 80 + + amount = invoice.amount + vat_amount = (amount * VAT_RATE / (1 + VAT_RATE)).quantize(Decimal("0.01")) + net_amount = amount - vat_amount + + pdf.set_xy(totals_x, totals_y) + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*gray) + pdf.cell(40, 7, "Subtotal (excl. VAT)") + pdf.set_text_color(*dark) + pdf.cell( + 40, + 7, + f"{invoice.currency} {net_amount:.2f}", + align="R", + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + + pdf.set_xy(totals_x, totals_y + 8) + pdf.set_text_color(*gray) + pdf.cell(40, 7, "VAT 21%") + pdf.set_text_color(*dark) + pdf.cell( + 40, + 7, + f"{invoice.currency} {vat_amount:.2f}", + align="R", + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + + div_y = totals_y + 17 + pdf.set_draw_color(*border_color) + pdf.line(totals_x, div_y, page_w - margin, div_y) + + pdf.set_xy(totals_x, div_y + 3) + pdf.set_font("Helvetica", "B", 11) + pdf.set_text_color(*dark) + pdf.cell(40, 9, "Total (incl. VAT)") + pdf.set_text_color(*green) + pdf.set_font("Helvetica", "B", 12) + pdf.cell( + 40, + 9, + f"{invoice.currency} {invoice.amount:.2f}", + align="R", + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + + footer_y = 272 + pdf.set_draw_color(*border_color) + pdf.set_line_width(0.3) + pdf.line(margin, footer_y, page_w - margin, footer_y) + + pdf.set_xy(margin, footer_y + 4) + pdf.set_font("Helvetica", "", 7.5) + pdf.set_text_color(*light_gray) + pdf.cell(content_w / 2, 4, "My App", new_x=XPos.RIGHT, new_y=YPos.TOP) + pdf.cell( + content_w / 2, + 4, + "Thank you for your business", + align="R", + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + + pdf.set_xy(margin, footer_y + 9) + pdf.cell(content_w / 2, 4, "my-app.com", new_x=XPos.RIGHT, new_y=YPos.TOP) + pdf.cell( + content_w / 2, + 4, + f"Invoice #{str(invoice.id).zfill(6)}", + align="R", + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + + return bytes(pdf.output()) + + +@api.route("/workspaces//billing") +class WorkspaceBilling(Resource): + @login_required + def get(self, workspace_id: int): + workspace, _ = _get_workspace_and_assert_owner_or_admin(workspace_id) + sub = get_or_create_subscription(workspace_id) + + if sub.status == SubscriptionStatus.PENDING: + _resolve_stale_pending(sub) + + member_count = WorkspaceMember.query.filter_by(workspace_id=workspace_id).count() + + result = WorkspaceSubscriptionSchema().dump(sub) + result["member_count"] = member_count + result["seat_price"] = str(sub.seat_price) + result["billing_exempt"] = sub.billing_exempt + result["trial_days_remaining"] = get_trial_days_remaining(sub) + result["paid_until"] = ( + sub.paid_until.isoformat() + "Z" if sub.paid_until else None + ) + + is_frozen, frozen_reason = get_workspace_frozen_state(sub) + result["is_frozen"] = is_frozen + result["frozen_reason"] = frozen_reason + + result["next_invoice_date"] = None + if ( + sub.plan == SubscriptionPlan.PAID + and sub.mollie_customer_id + and sub.mollie_subscription_id + ): + try: + mollie = _get_mollie_client() + customer = mollie.customers.get(sub.mollie_customer_id) + mollie_sub = customer.subscriptions.get(sub.mollie_subscription_id) + result["next_invoice_date"] = mollie_sub.get("nextPaymentDate") + except Exception: + pass + + return result + + +@api.route("/workspaces//billing/seats") +class WorkspaceBillingSeats(Resource): + @login_required + def patch(self, workspace_id: int): + workspace, _ = _get_workspace_and_assert_owner_or_admin(workspace_id) + sub = get_or_create_subscription(workspace_id) + + data = request.get_json() or {} + seats = data.get("seats") + if not isinstance(seats, int) or seats < 1: + raise APIError(APIErrorEnum.billing_error, "Invalid seat count", 400) + + member_count = WorkspaceMember.query.filter_by(workspace_id=workspace_id).count() + if seats < member_count: + raise APIError( + APIErrorEnum.billing_error, + f"Seat count cannot be less than the current number of members ({member_count})", + 400, + ) + + old_seats = sub.seats + sub.seats = seats + + if ( + sub.plan == SubscriptionPlan.PAID + and sub.status == SubscriptionStatus.ACTIVE + and sub.mollie_customer_id + and sub.mollie_subscription_id + and seats != old_seats + ): + try: + mollie = _get_mollie_client() + amount = sub.seat_price * seats + customer = mollie.customers.get(sub.mollie_customer_id) + customer.subscriptions.update( + sub.mollie_subscription_id, + { + "amount": {"currency": "EUR", "value": f"{amount:.2f}"}, + "description": f"Subscription - {seats} seat{'s' if seats != 1 else ''}", + "metadata": { + "workspace_id": str(workspace_id), + "seats": str(seats), + }, + }, + ) + except Exception as e: + current_app.logger.error( + "Failed to update Mollie subscription for workspace %d: %s", + workspace_id, + str(e), + ) + sub.seats = old_seats + raise APIError( + APIErrorEnum.billing_error, + "Failed to update subscription. Please try again.", + 502, + ) + + db.session.commit() + result = WorkspaceSubscriptionSchema().dump(sub) + result["seat_price"] = str(sub.seat_price) + result["member_count"] = member_count + return result + + +@api.route("/workspaces//billing/checkout") +class WorkspaceBillingCheckout(Resource): + @login_required + def post(self, workspace_id: int): + workspace, _ = _get_workspace_and_assert_owner_or_admin(workspace_id) + sub = get_or_create_subscription(workspace_id) + + if sub.plan == SubscriptionPlan.PAID and sub.status == SubscriptionStatus.ACTIVE: + raise APIError( + APIErrorEnum.billing_error, + "Workspace already has an active subscription", + 409, + ) + + if sub.status == SubscriptionStatus.PENDING: + _resolve_stale_pending(sub) + + if sub.status == SubscriptionStatus.PENDING: + raise APIError( + APIErrorEnum.billing_error, + "A payment is already being processed. Please wait for it to complete.", + 409, + ) + + member_count = WorkspaceMember.query.filter_by(workspace_id=workspace_id).count() + seats = max(member_count, 1) + amount = sub.seat_price * seats + + mollie = _get_mollie_client() + + if sub.mollie_customer_id: + try: + customer = mollie.customers.get(sub.mollie_customer_id) + except Exception: + customer = None + else: + customer = None + + if customer is None: + billing_email = sub.billing_email or current_user.email + customer = mollie.customers.create( + { + "name": workspace.name, + "email": billing_email, + "metadata": {"workspace_id": str(workspace_id)}, + } + ) + sub.mollie_customer_id = customer["id"] + db.session.commit() + + try: + payment = mollie.payments.create( + { + "amount": {"currency": "EUR", "value": f"{amount:.2f}"}, + "customerId": sub.mollie_customer_id, + "sequenceType": "first", + "description": f"Subscription - {seats} seat{'s' if seats != 1 else ''}", + "redirectUrl": f"{MY_SOLID_APP_FRONTEND_URL}/dashboard?billing_status=success&workspace_id={workspace_id}", + "cancelUrl": f"{MY_SOLID_APP_FRONTEND_URL}/dashboard?billing_status=cancelled&workspace_id={workspace_id}", + "webhookUrl": f"{MY_SOLID_APP_API_URL}/billing/webhook", + "metadata": { + "workspace_id": str(workspace_id), + "seats": str(seats), + }, + } + ) + except Exception as e: + current_app.logger.error("Mollie payment creation failed: %s", str(e)) + raise APIError( + APIErrorEnum.billing_error, + "Failed to create payment. Please try again.", + 502, + ) + + existing_invoice = Invoice.query.filter_by( + mollie_payment_id=payment["id"] + ).first() + if existing_invoice is None: + invoice = Invoice( + workspace_id=workspace_id, + subscription_id=sub.id, + mollie_payment_id=payment["id"], + amount=amount, + currency="EUR", + status=InvoiceStatus.PENDING, + description=f"Subscription - {seats} seat{'s' if seats != 1 else ''}", + seats=seats, + ) + db.session.add(invoice) + + sub.status = SubscriptionStatus.PENDING + sub.seats = seats + db.session.commit() + + checkout_url = payment["_links"]["checkout"]["href"] + return {"checkout_url": checkout_url} + + @login_required + def delete(self, workspace_id: int): + """Cancel a pending checkout so the user can start a fresh one.""" + workspace, _ = _get_workspace_and_assert_owner_or_admin(workspace_id) + sub = get_or_create_subscription(workspace_id) + + if sub.status != SubscriptionStatus.PENDING: + raise APIError( + APIErrorEnum.billing_error, + "No pending checkout to cancel.", + 409, + ) + + latest_invoice = ( + Invoice.query.filter_by(subscription_id=sub.id, status=InvoiceStatus.PENDING) + .order_by(Invoice.created_at.desc()) + .first() + ) + + if latest_invoice and latest_invoice.mollie_payment_id: + try: + mollie = _get_mollie_client() + payment = mollie.payments.get(latest_invoice.mollie_payment_id) + if payment["status"] == "open": + mollie.payments.delete(latest_invoice.mollie_payment_id) + except Exception as e: + current_app.logger.warning( + "Failed to cancel Mollie payment %s: %s", + latest_invoice.mollie_payment_id, + str(e), + ) + + if latest_invoice: + latest_invoice.status = InvoiceStatus.FAILED + + sub.status = SubscriptionStatus.ACTIVE + sub.plan = SubscriptionPlan.FREE_TRIAL + db.session.commit() + + return {"status": "cancelled"} + + +@api.route("/workspaces//billing/subscription") +class WorkspaceBillingSubscription(Resource): + @login_required + def delete(self, workspace_id: int): + workspace, _ = _get_workspace_and_assert_owner_or_admin(workspace_id) + sub = WorkspaceSubscription.query.filter_by(workspace_id=workspace_id).first() + + if sub is None or sub.plan != SubscriptionPlan.PAID: + raise APIError( + APIErrorEnum.subscription_not_found, + "No active paid subscription found", + 404, + ) + + next_payment_date = None + if sub.mollie_customer_id and sub.mollie_subscription_id: + try: + mollie = _get_mollie_client() + customer = mollie.customers.get(sub.mollie_customer_id) + mollie_sub = customer.subscriptions.get(sub.mollie_subscription_id) + next_date_str = mollie_sub.get("nextPaymentDate") + if next_date_str: + next_payment_date = datetime.strptime( + next_date_str, "%Y-%m-%d" + ).replace(tzinfo=timezone.utc) + customer.subscriptions.delete(sub.mollie_subscription_id) + except Exception as e: + current_app.logger.warning( + "Failed to cancel Mollie subscription %s: %s", + sub.mollie_subscription_id, + str(e), + ) + + sub.status = SubscriptionStatus.CANCELLED + sub.paid_until = next_payment_date + sub.mollie_subscription_id = None + db.session.commit() + + return {}, 200 + + +@api.route("/workspaces//billing/invoices") +class WorkspaceBillingInvoices(Resource): + @login_required + def get(self, workspace_id: int): + _get_workspace_and_assert_owner_or_admin(workspace_id) + + limit = request.args.get("limit", 5, type=int) + offset = request.args.get("offset", 0, type=int) + limit = min(max(limit, 1), 50) + offset = max(offset, 0) + + query = Invoice.query.filter_by(workspace_id=workspace_id).order_by( + Invoice.created_at.desc() + ) + total = query.count() + invoices = query.offset(offset).limit(limit).all() + + return { + "invoices": InvoiceSchema(many=True).dump(invoices), + "has_more": (offset + limit) < total, + } + + +@api.route("/workspaces//billing/invoices//download") +class WorkspaceBillingInvoiceDownload(Resource): + @login_required + def get(self, workspace_id: int, invoice_id: int): + workspace, _ = _get_workspace_and_assert_owner_or_admin(workspace_id) + + invoice = Invoice.query.filter_by( + id=invoice_id, workspace_id=workspace_id + ).first() + if invoice is None: + raise APIError(APIErrorEnum.invoice_not_found, "Invoice not found", 404) + + if invoice.status != InvoiceStatus.PAID: + raise APIError( + APIErrorEnum.billing_error, + "Invoice is not available for download", + 400, + ) + + pdf_bytes = _generate_invoice_pdf(invoice, workspace.name) + return send_file( + io.BytesIO(pdf_bytes), + mimetype="application/pdf", + as_attachment=True, + download_name=f"invoice-{invoice.id:06d}.pdf", + ) + + +@api.route("/workspaces//billing/update-payment-method") +class WorkspaceBillingUpdatePaymentMethod(Resource): + @login_required + def post(self, workspace_id: int): + workspace, _ = _get_workspace_and_assert_owner_or_admin(workspace_id) + sub = WorkspaceSubscription.query.filter_by(workspace_id=workspace_id).first() + + if sub is None or sub.status != SubscriptionStatus.ACTIVE: + raise APIError( + APIErrorEnum.billing_error, + "No active subscription found", + 404, + ) + + if not sub.mollie_customer_id: + raise APIError( + APIErrorEnum.billing_error, + "No payment customer on file. Please contact support.", + 400, + ) + + mollie = _get_mollie_client() + + try: + customer = mollie.customers.get(sub.mollie_customer_id) + except Exception: + raise APIError( + APIErrorEnum.billing_error, + "Failed to retrieve payment customer. Please try again.", + 502, + ) + + try: + payment = mollie.payments.create( + { + "amount": {"currency": "EUR", "value": "0.01"}, + "customerId": customer["id"], + "sequenceType": "first", + "description": "Update payment method", + "redirectUrl": f"{MY_SOLID_APP_FRONTEND_URL}/dashboard?billing_status=method_updated&workspace_id={workspace_id}", + "cancelUrl": f"{MY_SOLID_APP_FRONTEND_URL}/dashboard?billing_status=cancelled&workspace_id={workspace_id}", + "webhookUrl": f"{MY_SOLID_APP_API_URL}/billing/webhook", + "metadata": { + "workspace_id": str(workspace_id), + "type": "payment_method_update", + }, + } + ) + except Exception as e: + current_app.logger.error("Mollie payment method update failed: %s", str(e)) + raise APIError( + APIErrorEnum.billing_error, + "Failed to initiate payment method update. Please try again.", + 502, + ) + + checkout_url = payment["_links"]["checkout"]["href"] + return {"checkout_url": checkout_url} + + +@api.route("/workspaces//billing/payment-method") +class WorkspaceBillingPaymentMethod(Resource): + @login_required + def get(self, workspace_id: int): + _get_workspace_and_assert_owner_or_admin(workspace_id) + sub = WorkspaceSubscription.query.filter_by(workspace_id=workspace_id).first() + + empty = { + "method": None, + "card_label": None, + "card_last4": None, + "card_expiry": None, + } + + if sub is None or not sub.mollie_customer_id: + return empty + + try: + mollie = _get_mollie_client() + customer = mollie.customers.get(sub.mollie_customer_id) + mandates = customer.mandates.list() + + for mandate in mandates: + if mandate.get("status") not in ("valid", "pending"): + continue + + method = mandate.get("method", "") + details = mandate.get("details", {}) or {} + + if method == "creditcard": + return { + "method": "creditcard", + "card_label": details.get("cardLabel", "Card"), + "card_last4": details.get("cardNumber", "")[-4:] + if details.get("cardNumber") + else None, + "card_expiry": details.get("cardExpiryDate"), + } + elif method == "directdebit": + return { + "method": "directdebit", + "card_label": "SEPA Direct Debit", + "card_last4": details.get("consumerAccount", "")[-4:] + if details.get("consumerAccount") + else None, + "card_expiry": None, + } + else: + return { + "method": method, + "card_label": method.replace("_", " ").title(), + "card_last4": None, + "card_expiry": None, + } + except Exception as e: + current_app.logger.warning( + "Failed to fetch payment methods for workspace %d: %s", + workspace_id, + str(e), + ) + + return empty + + +@api.route("/billing/webhook") +class MollieWebhook(Resource): + def post(self): + """Mollie webhook handler — receives payment status updates.""" + payment_id = request.form.get("id") or (request.get_json(silent=True) or {}).get( + "id" + ) + if not payment_id: + return {}, 200 + + try: + mollie = _get_mollie_client() + payment = mollie.payments.get(payment_id) + except Exception as e: + current_app.logger.error( + "Mollie webhook: failed to fetch payment %s: %s", payment_id, str(e) + ) + return {}, 200 + + metadata = payment.get("metadata", {}) or {} + workspace_id_str = metadata.get("workspace_id") + seats_str = metadata.get("seats", "1") + + if not workspace_id_str: + invoice = Invoice.query.filter_by(mollie_payment_id=payment_id).first() + if invoice: + workspace_id_str = str(invoice.workspace_id) + seats_str = str(invoice.seats) + + if not workspace_id_str: + current_app.logger.warning( + "Mollie webhook: no workspace_id in metadata for payment %s", payment_id + ) + return {}, 200 + + workspace_id = int(workspace_id_str) + payment_status = payment["status"] + + if metadata.get("type") == "payment_method_update": + current_app.logger.info( + "Workspace %d: payment method update %s for payment %s", + workspace_id, + payment_status, + payment_id, + ) + return {}, 200 + + seats = int(seats_str) + amount_value = payment.get("amount", {}).get("value", "0.00") + amount = Decimal(amount_value) + description = payment.get("description", "Subscription") + + sub = WorkspaceSubscription.query.filter_by(workspace_id=workspace_id).first() + if sub is None: + current_app.logger.warning( + "Mollie webhook: no subscription for workspace %d", workspace_id + ) + return {}, 200 + + invoice = Invoice.query.filter_by(mollie_payment_id=payment_id).first() + if invoice is None: + invoice = Invoice( + workspace_id=workspace_id, + subscription_id=sub.id, + mollie_payment_id=payment_id, + amount=amount, + currency=payment.get("amount", {}).get("currency", "EUR"), + status=InvoiceStatus.PENDING, + description=description, + seats=seats, + ) + db.session.add(invoice) + + if payment_status == "paid": + invoice.status = InvoiceStatus.PAID + sub.plan = SubscriptionPlan.PAID + sub.status = SubscriptionStatus.ACTIVE + sub.seats = seats + db.session.commit() + + if payment.get("sequenceType") == "first" and not sub.mollie_subscription_id: + _create_mollie_subscription(mollie, sub, seats, description) + db.session.commit() + + current_app.logger.info( + "Workspace %d: payment %s paid, subscription activated", + workspace_id, + payment_id, + ) + + elif payment_status in ("failed", "expired", "canceled"): + invoice.status = InvoiceStatus.FAILED + if sub.status == SubscriptionStatus.PENDING: + sub.status = SubscriptionStatus.ACTIVE + sub.plan = SubscriptionPlan.FREE_TRIAL + current_app.logger.info( + "Workspace %d: payment %s %s", workspace_id, payment_id, payment_status + ) + db.session.commit() + + else: + db.session.commit() + + return {}, 200 + + +def _create_mollie_subscription( + mollie, sub: WorkspaceSubscription, seats: int, description: str +): + """Create a recurring Mollie subscription after the first payment.""" + amount = sub.seat_price * seats + interval = sub.billing_interval or "1 month" + + if interval == "1 month": + start_date = (date.today() + timedelta(days=32)).replace(day=1).isoformat() + elif interval.endswith("day") or interval.endswith("days"): + start_date = (date.today() + timedelta(days=1)).isoformat() + elif interval.endswith("week") or interval.endswith("weeks"): + start_date = (date.today() + timedelta(weeks=1)).isoformat() + else: + start_date = (date.today() + timedelta(days=32)).replace(day=1).isoformat() + + try: + customer = mollie.customers.get(sub.mollie_customer_id) + mollie_sub = customer.subscriptions.create( + { + "amount": {"currency": "EUR", "value": f"{amount:.2f}"}, + "interval": interval, + "startDate": start_date, + "description": description, + "webhookUrl": f"{MY_SOLID_APP_API_URL}/billing/webhook", + "metadata": { + "workspace_id": str(sub.workspace_id), + "seats": str(seats), + }, + } + ) + sub.mollie_subscription_id = mollie_sub["id"] + current_app.logger.info( + "Workspace %d: created Mollie subscription with interval=%s, startDate=%s", + sub.workspace_id, + interval, + start_date, + ) + except Exception as e: + current_app.logger.error( + "Failed to create Mollie subscription for workspace %d: %s", + sub.workspace_id, + str(e), + ) + raise diff --git a/api/app/resources/workspace.py b/api/app/resources/workspace.py new file mode 100644 index 0000000..ebe03e2 --- /dev/null +++ b/api/app/resources/workspace.py @@ -0,0 +1,430 @@ +from flask import current_app, request +from flask_login import current_user, login_required +from flask_restx import Resource +from marshmallow import Schema, fields + +from app.db.user import User +from app.db.workspace import ( + WORKSPACE_LANGUAGES, + Workspace, + WorkspaceInvitation, + WorkspaceInvitationSchema, + WorkspaceListItemSchema, + WorkspaceMember, + WorkspaceMemberRole, + WorkspaceSchema, +) +from app.errors import APIError, APIErrorEnum +from app.extensions import api, db +from app.resources.billing import ( + get_or_create_subscription, + get_workspace_frozen_state, + sync_subscription_seats, +) +from app.tasks.mail_tasks import ( + send_workspace_invitation_email, + send_workspace_invitation_new_user_email, +) + + +class CreateWorkspaceSchema(Schema): + name = fields.String(required=True) + + +class UpdateWorkspaceSchema(Schema): + name = fields.String(required=True) + color = fields.String(load_default=None, allow_none=True) + context = fields.String(load_default=None, allow_none=True) + language = fields.String(load_default=None, allow_none=True) + + +class InviteMemberSchema(Schema): + email = fields.String(required=True) + + +class UpdateMemberRoleSchema(Schema): + role = fields.String(required=True) + + +class AcceptInvitationSchema(Schema): + token = fields.String(required=True) + + +def _get_workspace_and_assert_member( + workspace_id: int, +) -> tuple[Workspace, WorkspaceMember]: + workspace = db.session.get(Workspace, workspace_id) + if workspace is None: + raise APIError(APIErrorEnum.workspace_not_found, "Workspace not found", 404) + + member = WorkspaceMember.query.filter_by( + workspace_id=workspace_id, user_id=current_user.id + ).first() + if member is None: + raise APIError( + APIErrorEnum.not_workspace_member, "Not a member of this workspace", 403 + ) + + return workspace, member + + +def _assert_owner_or_admin(member: WorkspaceMember): + if member.role not in (WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN): + raise APIError( + APIErrorEnum.not_workspace_owner_or_admin, + "This action requires owner or admin role", + 403, + ) + + +def _serialize_workspace_list( + workspaces_with_roles: list[tuple[Workspace, str]], +) -> list[dict]: + result = [] + for workspace, role in workspaces_with_roles: + item = WorkspaceListItemSchema().dump( + { + "id": workspace.id, + "name": workspace.name, + "color": workspace.color, + "setup_completed": workspace.setup_completed, + "role": role, + "member_count": len(workspace.members), + } + ) + sub = get_or_create_subscription(workspace.id) + is_frozen, frozen_reason = get_workspace_frozen_state(sub) + item["is_frozen"] = is_frozen + item["frozen_reason"] = frozen_reason + result.append(item) + return result + + +@api.route("/workspaces") +class WorkspaceList(Resource): + @login_required + def get(self): + memberships = WorkspaceMember.query.filter_by(user_id=current_user.id).all() + workspaces = [(Workspace.query.get(m.workspace_id), m.role) for m in memberships] + return _serialize_workspace_list(workspaces) + + @login_required + def post(self): + data: dict = CreateWorkspaceSchema().load(request.get_json()) + name = data.get("name", "").strip() + if not name: + raise APIError( + APIErrorEnum.workspace_not_found, "Workspace name is required", 400 + ) + + workspace, member = _create_workspace(name, current_user.id) + current_app.logger.info( + "User %d created workspace '%s' (id=%d)", current_user.id, name, workspace.id + ) + return WorkspaceSchema().dump(workspace) + + +def _create_workspace(name: str, owner_id: int) -> tuple[Workspace, WorkspaceMember]: + workspace = Workspace(name=name, created_by=owner_id) + db.session.add(workspace) + db.session.flush() + + member = WorkspaceMember( + workspace_id=workspace.id, + user_id=owner_id, + role=WorkspaceMemberRole.OWNER, + ) + db.session.add(member) + db.session.commit() + return workspace, member + + +def create_default_workspace(user: User): + """Called during registration to auto-create a workspace.""" + name = f"{user.name}'s Workspace" if user.name else "My Workspace" + return _create_workspace(name, user.id) + + +@api.route("/workspaces/") +class WorkspaceDetail(Resource): + @login_required + def get(self, workspace_id: int): + workspace, _ = _get_workspace_and_assert_member(workspace_id) + return WorkspaceSchema().dump(workspace) + + @login_required + def put(self, workspace_id: int): + workspace, member = _get_workspace_and_assert_member(workspace_id) + _assert_owner_or_admin(member) + + data: dict = UpdateWorkspaceSchema().load(request.get_json()) + name = data.get("name", "").strip() + if not name: + raise APIError( + APIErrorEnum.workspace_not_found, "Workspace name is required", 400 + ) + + workspace.name = name + if "color" in data: + workspace.color = data["color"] + if "context" in data: + workspace.context = data["context"] + if data.get("language"): + language = data["language"] + if language not in WORKSPACE_LANGUAGES: + raise APIError( + APIErrorEnum.unknown_error, + f"Language must be one of {list(WORKSPACE_LANGUAGES)}", + 400, + ) + workspace.language = language + db.session.add(workspace) + db.session.commit() + return WorkspaceSchema().dump(workspace) + + @login_required + def delete(self, workspace_id: int): + workspace, member = _get_workspace_and_assert_member(workspace_id) + if member.role != WorkspaceMemberRole.OWNER: + raise APIError( + APIErrorEnum.not_workspace_owner_or_admin, + "Only the owner can delete a workspace", + 403, + ) + db.session.delete(workspace) + db.session.commit() + return {}, 200 + + +@api.route("/workspaces//members/") +class WorkspaceMemberDetail(Resource): + @login_required + def patch(self, workspace_id: int, user_id: int): + workspace, member = _get_workspace_and_assert_member(workspace_id) + _assert_owner_or_admin(member) + + data: dict = UpdateMemberRoleSchema().load(request.get_json()) + new_role = data["role"] + if new_role not in (WorkspaceMemberRole.ADMIN, WorkspaceMemberRole.MEMBER): + raise APIError( + APIErrorEnum.unknown_error, + "Role must be 'admin' or 'member'", + 400, + ) + + target = WorkspaceMember.query.filter_by( + workspace_id=workspace_id, user_id=user_id + ).first() + if target is None: + raise APIError(APIErrorEnum.not_workspace_member, "Member not found", 404) + + if target.role in (WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN): + admin_owner_count = WorkspaceMember.query.filter( + WorkspaceMember.workspace_id == workspace_id, + WorkspaceMember.role.in_( + [WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN] + ), + ).count() + if admin_owner_count <= 1: + raise APIError( + APIErrorEnum.cannot_leave_last_owner, + "You are the last admin. Promote someone else before changing your role.", + 400, + ) + elif target.user_id == current_user.id: + raise APIError( + APIErrorEnum.unknown_error, + "Cannot change your own role", + 400, + ) + + target.role = new_role + db.session.commit() + return {}, 200 + + @login_required + def delete(self, workspace_id: int, user_id: int): + workspace, member = _get_workspace_and_assert_member(workspace_id) + + if user_id != current_user.id: + _assert_owner_or_admin(member) + + target = WorkspaceMember.query.filter_by( + workspace_id=workspace_id, user_id=user_id + ).first() + if target is None: + raise APIError(APIErrorEnum.not_workspace_member, "Member not found", 404) + + if target.role in (WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN): + admin_owner_count = WorkspaceMember.query.filter( + WorkspaceMember.workspace_id == workspace_id, + WorkspaceMember.role.in_( + [WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN] + ), + ).count() + if admin_owner_count <= 1: + raise APIError( + APIErrorEnum.cannot_leave_last_owner, + "You are the last admin. Promote someone else before leaving.", + 400, + ) + + db.session.delete(target) + db.session.commit() + + sync_subscription_seats(workspace_id) + + return {}, 200 + + +@api.route("/workspaces//invitations") +class WorkspaceInvitationList(Resource): + @login_required + def get(self, workspace_id: int): + workspace, member = _get_workspace_and_assert_member(workspace_id) + _assert_owner_or_admin(member) + + pending = WorkspaceInvitation.query.filter_by( + workspace_id=workspace_id, accepted=False + ).all() + return WorkspaceInvitationSchema(many=True).dump(pending) + + @login_required + def post(self, workspace_id: int): + workspace, member = _get_workspace_and_assert_member(workspace_id) + _assert_owner_or_admin(member) + + data: dict = InviteMemberSchema().load(request.get_json()) + email = data.get("email", "").strip().lower() + + invited_user = User.query.filter_by(email=email).first() + if invited_user: + existing = WorkspaceMember.query.filter_by( + workspace_id=workspace_id, user_id=invited_user.id + ).first() + if existing: + raise APIError( + APIErrorEnum.already_workspace_member, + "This user is already a member", + 409, + ) + + existing_inv = WorkspaceInvitation.query.filter_by( + workspace_id=workspace_id, email=email, accepted=False + ).first() + if existing_inv: + token = existing_inv.set_token() + else: + existing_inv = WorkspaceInvitation( + workspace_id=workspace_id, + email=email, + invited_by=current_user.id, + ) + token = existing_inv.set_token() + db.session.add(existing_inv) + + db.session.commit() + + if invited_user is not None: + send_workspace_invitation_email.delay( + receiver=email, + workspace_id=workspace.id, + workspace_name=workspace.name, + inviter_name=current_user.name or current_user.email, + invitation_token=token, + ) + else: + send_workspace_invitation_new_user_email.delay( + receiver=email, + workspace_id=workspace.id, + workspace_name=workspace.name, + inviter_name=current_user.name or current_user.email, + invitation_token=token, + ) + + current_app.logger.info( + "User %d invited %s to workspace %d", current_user.id, email, workspace_id + ) + return WorkspaceInvitationSchema().dump(existing_inv) + + +@api.route("/workspaces//invitations/") +class WorkspaceInvitationDetail(Resource): + @login_required + def delete(self, workspace_id: int, invitation_id: int): + _, member = _get_workspace_and_assert_member(workspace_id) + _assert_owner_or_admin(member) + + invitation = WorkspaceInvitation.query.filter_by( + id=invitation_id, workspace_id=workspace_id + ).first() + if invitation is None: + raise APIError( + APIErrorEnum.invitation_not_found, "Invitation not found", 404 + ) + + db.session.delete(invitation) + db.session.commit() + return {}, 200 + + +@api.route("/invitations/lookup") +class InvitationLookup(Resource): + def post(self): + data: dict = AcceptInvitationSchema().load(request.get_json()) + token = data.get("token") + + invitations = WorkspaceInvitation.query.filter_by(accepted=False).all() + matched = next((inv for inv in invitations if inv.check_token(token)), None) + if matched is None: + raise APIError( + APIErrorEnum.invitation_not_found, + "Invitation not found or already accepted", + 404, + ) + + workspace = Workspace.query.get(matched.workspace_id) + return { + "email": matched.email, + "workspace_name": workspace.name if workspace else "", + } + + +@api.route("/invitations/accept") +class AcceptInvitation(Resource): + @login_required + def post(self): + data: dict = AcceptInvitationSchema().load(request.get_json()) + token = data.get("token") + + invitations = WorkspaceInvitation.query.filter_by( + email=current_user.email.lower(), accepted=False + ).all() + + matched = next((inv for inv in invitations if inv.check_token(token)), None) + if matched is None: + raise APIError( + APIErrorEnum.invitation_not_found, + "Invitation not found or already accepted", + 404, + ) + + existing = WorkspaceMember.query.filter_by( + workspace_id=matched.workspace_id, user_id=current_user.id + ).first() + joined_now = existing is None + if joined_now: + new_member = WorkspaceMember( + workspace_id=matched.workspace_id, + user_id=current_user.id, + role=WorkspaceMemberRole.MEMBER, + ) + db.session.add(new_member) + + matched.accepted = True + db.session.commit() + + sync_subscription_seats(matched.workspace_id) + + workspace = Workspace.query.get(matched.workspace_id) + return WorkspaceSchema().dump(workspace) diff --git a/api/app/tasks/mail_tasks.py b/api/app/tasks/mail_tasks.py index 3a2613f..bc1aa71 100644 --- a/api/app/tasks/mail_tasks.py +++ b/api/app/tasks/mail_tasks.py @@ -1,3 +1,4 @@ +import urllib.parse from string import Template from celery import shared_task @@ -58,3 +59,55 @@ def send_email_verification_email(*, receiver: str, verification_token: str): ) message.html mail.send(message) + + +@shared_task(ignore_result=True) +def send_workspace_invitation_email( + *, + receiver: str, + workspace_id: int, + workspace_name: str, + inviter_name: str, + invitation_token: str, +): + invitation_link = ( + f"{MY_SOLID_APP_FRONTEND_URL}/accept-invitation?" + f"invitation_token={urllib.parse.quote(invitation_token)}" + ) + + message = Message( + subject=f"{inviter_name} invited you to {workspace_name}", + recipients=[receiver], + html=f""" +

Hi,

+

{inviter_name} has invited you to join {workspace_name}.

+

Accept invitation

+ """, + ) + mail.send(message) + + +@shared_task(ignore_result=True) +def send_workspace_invitation_new_user_email( + *, + receiver: str, + workspace_id: int, + workspace_name: str, + inviter_name: str, + invitation_token: str, +): + register_link = ( + f"{MY_SOLID_APP_FRONTEND_URL}/register?" + f"invitation_token={urllib.parse.quote(invitation_token)}" + ) + + message = Message( + subject=f"{inviter_name} invited you to {workspace_name}", + recipients=[receiver], + html=f""" +

Hi,

+

{inviter_name} has invited you to join {workspace_name}.

+

Create account & accept invitation

+ """, + ) + mail.send(message) diff --git a/api/migrations/versions/826709b8f4a1_.py b/api/migrations/versions/826709b8f4a1_.py new file mode 100644 index 0000000..7db6579 --- /dev/null +++ b/api/migrations/versions/826709b8f4a1_.py @@ -0,0 +1,112 @@ +"""empty message + +Revision ID: 826709b8f4a1 +Revises: d29daf4c6bc4 +Create Date: 2026-04-26 15:13:44.230869 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '826709b8f4a1' +down_revision = 'd29daf4c6bc4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('workspace', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('setup_completed', sa.Boolean(), nullable=False), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('context', sa.String(length=2000), nullable=True), + sa.Column('language', sa.String(length=5), server_default='nl', nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('workspace_invitation', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workspace_id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=100), nullable=False), + sa.Column('hashed_token', sa.String(length=256), nullable=False), + sa.Column('invited_by', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('accepted', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['invited_by'], ['user.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('workspace_invitation', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_workspace_invitation_email'), ['email'], unique=False) + + op.create_table('workspace_member', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workspace_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('joined_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('workspace_subscription', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workspace_id', sa.Integer(), nullable=False), + sa.Column('plan', sa.String(length=20), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('mollie_customer_id', sa.String(length=50), nullable=True), + sa.Column('mollie_subscription_id', sa.String(length=50), nullable=True), + sa.Column('seats', sa.Integer(), nullable=False), + sa.Column('billing_email', sa.String(length=255), nullable=True), + sa.Column('billing_exempt', sa.Boolean(), nullable=False), + sa.Column('billing_interval', sa.String(length=20), nullable=True), + sa.Column('billing_price_override', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('paid_until', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('workspace_id') + ) + op.create_table('invoice', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workspace_id', sa.Integer(), nullable=False), + sa.Column('subscription_id', sa.Integer(), nullable=False), + sa.Column('mollie_payment_id', sa.String(length=50), nullable=False), + sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('currency', sa.String(length=3), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('description', sa.String(length=255), nullable=False), + sa.Column('seats', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['subscription_id'], ['workspace_subscription.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['workspace_id'], ['workspace.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('mollie_payment_id') + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('name', sa.String(length=100), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('name') + + op.drop_table('invoice') + op.drop_table('workspace_subscription') + op.drop_table('workspace_member') + with op.batch_alter_table('workspace_invitation', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_workspace_invitation_email')) + + op.drop_table('workspace_invitation') + op.drop_table('workspace') + # ### end Alembic commands ### diff --git a/api/requirements.txt b/api/requirements.txt index 74e2ea4..940a65e 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,14 +1,18 @@ celery==5.5.3 cryptography==46.0.3 flask==3.1.2 +flask-cors==5.0.1 flask-login==0.6.3 flask-migrate==4.1.0 flask-restx==1.3.2 flask-sqlalchemy==3.1.1 flask-mail==0.10.0 +fpdf2==2.8.3 gunicorn==23.0.0 marshmallow==4.0.1 marshmallow-sqlalchemy==1.4.2 +minio==7.2.15 +mollie-api-python==3.5.0 mysqlclient==2.2.7 pyotp==2.9.0 qrcode[pil]==8.2 diff --git a/api/scripts/add_fixtures.py b/api/scripts/add_fixtures.py index 3a95b1f..ec294ef 100644 --- a/api/scripts/add_fixtures.py +++ b/api/scripts/add_fixtures.py @@ -1,6 +1,7 @@ from app.app import create_app from app.config import DevConfig from app.db.user import User +from app.db.workspace import Workspace, WorkspaceMember, WorkspaceMemberRole from app.extensions import db @@ -10,14 +11,38 @@ def clear_database(): db.create_all() -def add_users(db): - user = User( - email="admin@test.nl", - is_admin=True, +def add_fixtures_data(db): + admin = User( + email="admin@test.nl", name="Admin User", is_admin=True, is_verified=True ) - user.set_password("admin") + admin.set_password("admin") - db.session.add(user) + member = User(email="member@test.nl", name="Member User", is_verified=True) + member.set_password("member") + + db.session.add_all([admin, member]) + db.session.flush() + + workspace = Workspace( + name="Default Workspace", created_by=admin.id, setup_completed=True + ) + db.session.add(workspace) + db.session.flush() + + db.session.add_all( + [ + WorkspaceMember( + workspace_id=workspace.id, + user_id=admin.id, + role=WorkspaceMemberRole.ADMIN, + ), + WorkspaceMember( + workspace_id=workspace.id, + user_id=member.id, + role=WorkspaceMemberRole.MEMBER, + ), + ] + ) db.session.commit() @@ -25,7 +50,7 @@ def add_fixtures(): app = create_app(config_object=DevConfig()) with app.app_context(): clear_database() - add_users(db) + add_fixtures_data(db) if __name__ == "__main__": diff --git a/config/k8s/app/backend-configmap.yaml b/config/k8s/app/backend-configmap.yaml index 4421494..26b1c13 100644 --- a/config/k8s/app/backend-configmap.yaml +++ b/config/k8s/app/backend-configmap.yaml @@ -9,4 +9,6 @@ data: MY_SOLID_APP_MAIL_PORT: "465" MY_SOLID_APP_MAIL_USE_SSL: "True" MY_SOLID_APP_PASSWORD_RESET_TOKEN_EXPIRE_HOURS: "1" + MY_SOLID_APP_MINIO_PORT: "9000" + MY_SOLID_APP_MINIO_SECURE: "False" PROMETHEUS_MULTIPROC_DIR: "/tmp" diff --git a/config/k8s/app/kustomization.yaml b/config/k8s/app/kustomization.yaml index 109dc18..731c98b 100644 --- a/config/k8s/app/kustomization.yaml +++ b/config/k8s/app/kustomization.yaml @@ -4,5 +4,6 @@ resources: - api.yaml - ui.yaml - tasks.yaml + - tasks-beat.yaml - redis.yaml - backend-configmap.yaml diff --git a/config/k8s/app/tasks-beat.yaml b/config/k8s/app/tasks-beat.yaml new file mode 100644 index 0000000..ff00119 --- /dev/null +++ b/config/k8s/app/tasks-beat.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-solid-app-tasks-beat +spec: + replicas: 1 + selector: + matchLabels: + app: my-solid-app-tasks-beat + template: + metadata: + labels: + app: my-solid-app-tasks-beat + spec: + imagePullSecrets: + - name: my-solid-app-dockerhub + containers: + - name: my-solid-app-tasks-beat + image: /my-solid-app-tasks + command: ["celery"] + args: + [ + "-A", + "server.celery_app", + "beat", + "--loglevel=info", + "--schedule=/var/run/celery/celerybeat-schedule", + ] + envFrom: + - configMapRef: + name: my-solid-app-backend-config + - secretRef: + name: my-solid-app-backend-secrets + resources: + requests: + cpu: "50m" + memory: "128Mi" + limits: + cpu: "250m" + memory: "256Mi" + volumeMounts: + - name: celerybeat-schedule + mountPath: /var/run/celery + volumes: + - name: celerybeat-schedule + emptyDir: {} diff --git a/config/k8s/overlays/prod/kustomization.yaml b/config/k8s/overlays/prod/kustomization.yaml index 93a61fa..0c41d03 100644 --- a/config/k8s/overlays/prod/kustomization.yaml +++ b/config/k8s/overlays/prod/kustomization.yaml @@ -46,8 +46,13 @@ configMapGenerator: behavior: merge literals: - MY_SOLID_APP_FRONTEND_URL="https://app.my-solid-app.com" + - MY_SOLID_APP_API_URL="https://api.my-solid-app.com" - MY_SOLID_APP_DB_NAME="my_solid_app_db" - MY_SOLID_APP_DB_HOST="" + - MY_SOLID_APP_MINIO_HOST="" + - MY_SOLID_APP_MINIO_PORT="" + - MY_SOLID_APP_MINIO_BUCKET="my-solid-app" + - MY_SOLID_APP_MINIO_SECURE="True" secretGenerator: - name: my-solid-app-backend-secrets @@ -60,3 +65,6 @@ secretGenerator: - MY_SOLID_APP_MAIL_USERNAME=${MY_SOLID_APP_MAIL_USERNAME} - MY_SOLID_APP_MAIL_PASSWORD=${MY_SOLID_APP_MAIL_PASSWORD} - MY_SOLID_APP_MAIL_DEFAULT_SENDER=${MY_SOLID_APP_MAIL_DEFAULT_SENDER} + - MY_SOLID_APP_MOLLIE_API_KEY=${MY_SOLID_APP_MOLLIE_API_KEY} + - MY_SOLID_APP_MINIO_USER=${MY_SOLID_APP_MINIO_USER} + - MY_SOLID_APP_MINIO_PASSWORD=${MY_SOLID_APP_MINIO_PASSWORD} diff --git a/config/k8s/overlays/staging/kustomization.yaml b/config/k8s/overlays/staging/kustomization.yaml index 8e2b83c..32f3dc2 100644 --- a/config/k8s/overlays/staging/kustomization.yaml +++ b/config/k8s/overlays/staging/kustomization.yaml @@ -32,8 +32,13 @@ configMapGenerator: behavior: merge literals: - MY_SOLID_APP_FRONTEND_URL="https://staging.app.my-solid-app.com" + - MY_SOLID_APP_API_URL="https://staging.api.my-solid-app.com" - MY_SOLID_APP_DB_NAME="my_solid_app_staging_db" - MY_SOLID_APP_DB_HOST="" + - MY_SOLID_APP_MINIO_HOST="" + - MY_SOLID_APP_MINIO_PORT="" + - MY_SOLID_APP_MINIO_BUCKET="my-solid-app-staging" + - MY_SOLID_APP_MINIO_SECURE="True" secretGenerator: - name: my-solid-app-backend-secrets @@ -46,3 +51,6 @@ secretGenerator: - MY_SOLID_APP_MAIL_USERNAME=${MY_SOLID_APP_STAGING_MAIL_USERNAME} - MY_SOLID_APP_MAIL_PASSWORD=${MY_SOLID_APP_STAGING_MAIL_PASSWORD} - MY_SOLID_APP_MAIL_DEFAULT_SENDER=${MY_SOLID_APP_STAGING_MAIL_DEFAULT_SENDER} + - MY_SOLID_APP_MOLLIE_API_KEY=${MY_SOLID_APP_STAGING_MOLLIE_API_KEY} + - MY_SOLID_APP_MINIO_USER=${MY_SOLID_APP_STAGING_MINIO_USER} + - MY_SOLID_APP_MINIO_PASSWORD=${MY_SOLID_APP_STAGING_MINIO_PASSWORD} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0fd6940..045156e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,3 +26,19 @@ services: ports: - "1080:1080" - "1025:1025" + + minio: + image: minio/minio:latest + container_name: my-solid-app-minio + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - minio_data:/data + +volumes: + minio_data: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d52e7e4 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,149 @@ +services: + ui: + image: /my-solid-app-ui:stable + container_name: my-solid-app-ui + volumes: + - '/var/www/certbot:/var/www/certbot' + - '/etc/letsencrypt/:/etc/letsencrypt/' + - '/home/${MY_SOLID_APP_SERVER_USER}/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro' + ports: + - '80:80' + - '443:443' + networks: + - my-solid-app-network + depends_on: + - api + logging: + driver: 'json-file' + options: + max-size: '5m' + max-file: '10' + + api: + image: /my-solid-app-api:stable + container_name: my-solid-app-api + environment: + MY_SOLID_APP_FRONTEND_URL: https://app.my-solid-app.com + MY_SOLID_APP_API_URL: https://api.my-solid-app.com + MY_SOLID_APP_SECRET_KEY: $MY_SOLID_APP_SECRET_KEY + MY_SOLID_APP_FERNET_SECRET_KEY: $MY_SOLID_APP_FERNET_SECRET_KEY + MY_SOLID_APP_DB_NAME: $MY_SOLID_APP_DB_NAME + MY_SOLID_APP_DB_USER: $MY_SOLID_APP_DB_USER + MY_SOLID_APP_DB_PASSWORD: $MY_SOLID_APP_DB_PASSWORD + MY_SOLID_APP_DB_HOST: 'db' + MY_SOLID_APP_DB_PORT: '3306' + MY_SOLID_APP_REDIS_HOST: 'redis' + MY_SOLID_APP_MAIL_SERVER: $MY_SOLID_APP_MAIL_SERVER + MY_SOLID_APP_MAIL_PORT: $MY_SOLID_APP_MAIL_PORT + MY_SOLID_APP_MAIL_USE_SSL: 'True' + MY_SOLID_APP_MAIL_USERNAME: $MY_SOLID_APP_MAIL_USERNAME + MY_SOLID_APP_MAIL_PASSWORD: $MY_SOLID_APP_MAIL_PASSWORD + MY_SOLID_APP_MAIL_DEFAULT_SENDER: $MY_SOLID_APP_MAIL_DEFAULT_SENDER + MY_SOLID_APP_PASSWORD_RESET_TOKEN_EXPIRE_HOURS: 1 + MY_SOLID_APP_MOLLIE_API_KEY: $MY_SOLID_APP_MOLLIE_API_KEY + MY_SOLID_APP_MINIO_HOST: $MY_SOLID_APP_MINIO_HOST + MY_SOLID_APP_MINIO_PORT: $MY_SOLID_APP_MINIO_PORT + MY_SOLID_APP_MINIO_USER: $MY_SOLID_APP_MINIO_USER + MY_SOLID_APP_MINIO_PASSWORD: $MY_SOLID_APP_MINIO_PASSWORD + MY_SOLID_APP_MINIO_BUCKET: $MY_SOLID_APP_MINIO_BUCKET + MY_SOLID_APP_MINIO_SECURE: 'True' + networks: + - my-solid-app-network + depends_on: + - db + logging: + driver: 'json-file' + options: + max-size: '5m' + max-file: '5' + + tasks: + image: /my-solid-app-tasks:stable + container_name: my-solid-app-tasks + environment: + MY_SOLID_APP_FRONTEND_URL: https://app.my-solid-app.com + MY_SOLID_APP_API_URL: https://api.my-solid-app.com + MY_SOLID_APP_SECRET_KEY: $MY_SOLID_APP_SECRET_KEY + MY_SOLID_APP_FERNET_SECRET_KEY: $MY_SOLID_APP_FERNET_SECRET_KEY + MY_SOLID_APP_DB_NAME: $MY_SOLID_APP_DB_NAME + MY_SOLID_APP_DB_USER: $MY_SOLID_APP_DB_USER + MY_SOLID_APP_DB_PASSWORD: $MY_SOLID_APP_DB_PASSWORD + MY_SOLID_APP_DB_HOST: 'db' + MY_SOLID_APP_DB_PORT: '3306' + MY_SOLID_APP_REDIS_HOST: 'redis' + MY_SOLID_APP_MAIL_SERVER: $MY_SOLID_APP_MAIL_SERVER + MY_SOLID_APP_MAIL_PORT: $MY_SOLID_APP_MAIL_PORT + MY_SOLID_APP_MAIL_USE_SSL: 'True' + MY_SOLID_APP_MAIL_USERNAME: $MY_SOLID_APP_MAIL_USERNAME + MY_SOLID_APP_MAIL_PASSWORD: $MY_SOLID_APP_MAIL_PASSWORD + MY_SOLID_APP_MAIL_DEFAULT_SENDER: $MY_SOLID_APP_MAIL_DEFAULT_SENDER + MY_SOLID_APP_MOLLIE_API_KEY: $MY_SOLID_APP_MOLLIE_API_KEY + networks: + - my-solid-app-network + depends_on: + - redis + - db + logging: + driver: 'json-file' + options: + max-size: '5m' + max-file: '5' + + db: + image: mariadb:latest + container_name: my-solid-app-mariadb + restart: always + environment: + MYSQL_ROOT_PASSWORD: $MYSQL_ROOT_PASSWORD + MYSQL_DATABASE: $MY_SOLID_APP_DB_NAME + MYSQL_USER: $MY_SOLID_APP_DB_USER + MYSQL_PASSWORD: $MY_SOLID_APP_DB_PASSWORD + volumes: + - db_data_prod:/var/lib/mysql + networks: + - my-solid-app-network + logging: + driver: 'json-file' + options: + max-size: '5m' + max-file: '5' + + redis: + image: redis:latest + container_name: my-solid-app-redis + command: redis-server --appendonly yes + volumes: + - redis_data_prod:/data + networks: + - my-solid-app-network + logging: + driver: 'json-file' + options: + max-size: '5m' + max-file: '5' + + certbot: + image: certbot/certbot:latest + container_name: my-solid-app-certbot + volumes: + - '/var/www/certbot:/var/www/certbot' + - '/var/lib/letsencrypt/:/var/lib/letsencrypt/' + - '/etc/letsencrypt/:/etc/letsencrypt/' + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot -v; sleep 12h & wait $${!}; done;'" + networks: + - my-solid-app-network + logging: + driver: 'json-file' + options: + max-size: '1m' + max-file: '3' + + +networks: + my-solid-app-network: + driver: bridge + + +volumes: + db_data_prod: + redis_data_prod: diff --git a/nginx.prod.conf b/nginx.prod.conf new file mode 100644 index 0000000..bd6216d --- /dev/null +++ b/nginx.prod.conf @@ -0,0 +1,66 @@ +server { + listen 80; + server_name app.; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name app.; + + ssl_certificate /etc/letsencrypt/live/app./fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app./privkey.pem; + + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' https://cdnjs.cloudflare.com; connect-src 'self' https://api.; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } +} + +server { + listen 80; + server_name api.; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name api.; + + ssl_certificate /etc/letsencrypt/live/api./fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api./privkey.pem; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + location / { + proxy_pass http://api:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + client_max_body_size 210m; + } +} diff --git a/ui/cypress/e2e/admin.cy.js b/ui/cypress/e2e/admin.cy.js index e4f4a46..ac5f684 100644 --- a/ui/cypress/e2e/admin.cy.js +++ b/ui/cypress/e2e/admin.cy.js @@ -6,125 +6,45 @@ describe('admin', () => { it('Create new user', () => { cy.visit('/') - cy.get('.hidden > :nth-child(1)').click(); - - cy.wait(100); - - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('ad'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('admin'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - - cy.wait(100); - - cy.get('.text').click(); - cy.get('p.text-success').click(); - cy.get('thead > tr > .text-end > .btn').click(); - - cy.wait(100); - - cy.get('#email').clear('te'); - cy.get('#email').type('test@test.nl'); - cy.get('#password').clear(); - cy.get('#password').type('Testing1!'); - cy.get('.modal-action > .btn').click(); - - cy.wait(100); - - cy.get(':nth-child(3) > summary.btn').click(); - cy.wait(50); - cy.get('.menu > :nth-child(3) > .btn').click(); - cy.wait(50); - cy.get('.hidden > :nth-child(1)').click(); - cy.wait(50); - - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('test@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('Testing1!'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - - cy.wait(50); - + cy.login("admin@test.nl", "admin") + cy.navigateToAdminPanel() + cy.createUser("user@test.nl", "Testing1!", false) + cy.logout() + cy.login("user@test.nl", "Testing1!") cy.url().should('include', '/home') }) it('Create another admin', function() { - cy.visit('/'); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('admin'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - cy.get('.text').click(); - cy.get('p.text-success').click(); - cy.get('thead > tr > .text-end').click(); - cy.get('.modal-box').click(); - cy.get('#email').clear('ad'); - cy.get('#email').type('admin2@test.nl'); - cy.get('#password').clear(); - cy.get('#password').type('Testing1@'); - cy.get('.checkbox').check(); - cy.get('.modal-action > .btn').click(); - cy.get('.text').click(); - cy.get('.menu > :nth-child(3) > .btn').click(); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin2@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('Testing1@'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - cy.get('.text').click(); - cy.get(':nth-child(2) > .btn > .fa-solid').click(); + cy.visit('/') + cy.login("admin@test.nl", "admin") + cy.navigateToAdminPanel() + cy.createUser("another@admin.nl", "AdminAwesome1!", true) + cy.logout() + cy.login("another@admin.nl", "AdminAwesome1!") + cy.navigateToAdminPanel() cy.url().should('include', '/admin-panel') }); it('Delete user', function() { cy.visit('/'); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('ad'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('admin'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - cy.get('.text').click(); - cy.get('p.text-success').click(); - cy.get('thead > tr > .text-end > .btn > .fa-solid').click(); - cy.get('#email').clear('te'); - cy.get('#email').type('test@test.nl'); - cy.get('#password').clear(); - cy.get('#password').type('Testing1!'); - cy.get('.modal-action > .btn').click(); - cy.get('.text').click(); - cy.get('.menu > :nth-child(3) > .btn').click(); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('te'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('test@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('Testing1!'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - cy.get('.text').click(); - cy.get('[open=""] > .menu > :nth-child(2) > .btn').click(); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('ad'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('admin'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - cy.get('.text').click(); - cy.get('p.text-success').click(); - cy.get(':nth-child(2) > .text-end > .btn > .fa-solid').click(); - cy.get('.btn-error').click(); - cy.get('.text').click(); - cy.get('.menu > :nth-child(3) > .btn > .fa-solid').click(); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('te'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('test@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('Testing1!'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - cy.get('.alert span').should('contain.text', 'Could not login with the given email and password'); + cy.login("admin@test.nl", "admin") + cy.navigateToAdminPanel() + cy.createUser("user@test.nl", "Testing1!", false) + cy.logout() + + cy.login("user@test.nl", "Testing1!") + cy.url().should('include', '/home') + cy.logout() + + cy.login("admin@test.nl", "admin") + cy.navigateToAdminPanel() + + cy.deleteUser("user@test.nl") + cy.logout() + + cy.login("user@test.nl", "Testing1!") + cy.get('[data-cy="login-error"]').should('contain.text', 'Could not login with the given email and password'); }); }) diff --git a/ui/cypress/e2e/authentication.cy.js b/ui/cypress/e2e/authentication.cy.js index c894020..b1e239f 100644 --- a/ui/cypress/e2e/authentication.cy.js +++ b/ui/cypress/e2e/authentication.cy.js @@ -4,145 +4,51 @@ describe('authentication', () => { }) it('Login as admin', () => { - cy.visit('/') - - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('ad'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('admin'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - + cy.login('admin@test.nl', 'admin') cy.url().should('include', '/home') }) - it('Register new user', function() { - cy.visit('/'); - - cy.get('.hidden > :nth-child(2)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('b'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('test@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('Testing1!'); - cy.get('#checkPassword').clear(); - cy.get('#checkPassword').type('Testing1!'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - - cy.url().should('include', '/home') - }); - - it('Logout', function() { - cy.visit('/'); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('ad'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('admin'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - + it('Register new user', () => { + cy.register('Test User', 'test@test.nl', 'Testing1!') cy.url().should('include', '/home') - cy.wait(200); - - cy.get(':nth-child(3) > summary.btn').click(); - cy.get('.menu > :nth-child(3) > .btn').click(); + }) + it('Logout', () => { + cy.login('admin@test.nl', 'admin') + cy.logout() cy.url().should('not.include', '/home') - }); - - it('Forgot password', function() { - cy.visit('/'); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.space-y-4 > .justify-center').click(); - cy.get('form > :nth-child(1) > .input > #email').clear('ad'); - cy.get('form > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.justify-center > button.btn').click(); - }); - - it('Login failed with incorrect password', function() { - cy.visit('/'); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('ad'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('test'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); + }) - cy.get('.alert span').should('contain.text', 'Could not login with the given email and password'); - }); + it('Forgot password', () => { + cy.forgotPassword('admin@test.nl') + cy.get('[data-cy="forgot-password-success"]').should('exist') + }) - it('Register failed email already exists', function() { - cy.visit('/'); - cy.get('.hidden > :nth-child(2)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('ad'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('Testing1!'); - cy.get('#checkPassword').clear(); - cy.get('#checkPassword').type('Testing1!{enter}'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); + it('Login failed with incorrect password', () => { + cy.login('admin@test.nl', 'wrong-password') + cy.get('[data-cy="login-error"]').should('contain.text', 'Could not login with the given email and password') + }) - cy.get('.alert span').should('contain.text', 'An account with this email already exists'); + it('Register failed email already exists', () => { + cy.register('Admin User', 'admin@test.nl', 'Testing1!') + cy.get('[data-cy="register-error"]').should('contain.text', 'An account with this email already exists') cy.url().should('not.include', '/home') - }); - - it('Change password', function() { - cy.visit('/'); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('ad'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('admin'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - - cy.url().should('include', '/home') - - cy.get('.text').click(); - cy.get('[open=""] > .menu > :nth-child(1) > .btn').click(); - cy.get(':nth-child(2) > .text-end > .tooltip > .btn > .fa-solid').click(); - cy.get('#currentPassword').clear('ad'); - cy.get('#currentPassword').type('admin'); - cy.get('#newPassword').clear(); - cy.get('#newPassword').type('Testing1@'); - cy.get('#confirmNewPassword').clear(); - cy.get('#confirmNewPassword').type('Testing1@{enter}'); - - cy.get('.text').click(); - cy.get('.menu > :nth-child(3) > .btn').click(); - cy.get('.hidden > :nth-child(1)').click(); - - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('ad'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('admin@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('Testing1@'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - - cy.url().should('include', '/home') - }); - - it('Delete own account', function() { - cy.visit('/'); - cy.get('.hidden > :nth-child(2)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('te'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('test@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('Testing1!'); - cy.get('#checkPassword').clear(); - cy.get('#checkPassword').type('Testing1!'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); + }) + it('Change password', () => { + cy.login('admin@test.nl', 'admin') + cy.navigateToAccount() + cy.changePassword('admin', 'Testing1@') + cy.logout() + cy.login('admin@test.nl', 'Testing1@') cy.url().should('include', '/home') + }) - cy.get('.text').click(); - cy.get('[open=""] > .menu > :nth-child(1) > .btn').click(); - cy.get('div.mt-4 > .btn').click(); - cy.get('.modal-action > .btn-error').click(); - cy.get('.hidden > :nth-child(1)').click(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').clear('te'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(1) > .input > #email').type('test@test.nl'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').clear(); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > :nth-child(2) > .input > #password').type('Testing1!{enter}'); - cy.get('.modal-open > .modal-box > form.w-full > .space-y-4 > .modal-action > .btn').click(); - - cy.get('.alert span').should('contain.text', 'Could not login with the given email and password'); - }); + it('Delete own account', () => { + cy.register('Test User', 'test@test.nl', 'Testing1!') + cy.navigateToAccount() + cy.deleteAccount() + cy.login('test@test.nl', 'Testing1!') + cy.get('[data-cy="login-error"]').should('contain.text', 'Could not login with the given email and password') + }) }) diff --git a/ui/cypress/e2e/home_page.cy.js b/ui/cypress/e2e/home_page.cy.js index 4c45e5f..890fbb0 100644 --- a/ui/cypress/e2e/home_page.cy.js +++ b/ui/cypress/e2e/home_page.cy.js @@ -1,5 +1,6 @@ -describe('LandingPage', () => { - it('Visit LandingPage', () => { - cy.visit('/') +describe('home page', () => { + it('Redirects to login when not authenticated', () => { + cy.visit('/home') + cy.url().should('include', '/login') }) }) diff --git a/ui/cypress/e2e/locale.cy.js b/ui/cypress/e2e/locale.cy.js index 77ab2eb..64dfb95 100644 --- a/ui/cypress/e2e/locale.cy.js +++ b/ui/cypress/e2e/locale.cy.js @@ -1,12 +1,14 @@ describe('locale', () => { - it('Switch language to dutch', () => { - cy.visit('/') - - cy.get('h1').should('contain.text', 'This is your web application'); - - cy.get('.dropdown > .btn-sm').click(); - cy.get('.menu > :nth-child(2) > .btn > .flex').click(); + beforeEach(() => { + cy.exec('cd ../api && source .env/bin/activate && make fixtures') + cy.clearLocalStorage() + }) - cy.get('h1').should('contain.text', 'Dit is jouw web applicatie'); + it('Switch language to dutch', () => { + cy.login('admin@test.nl', 'admin') + cy.get('h1').should('contain.text', 'This is the home page') + cy.get('[data-cy="language-selector"]').click() + cy.get('[data-cy="language-nl"]').click() + cy.get('h1').should('contain.text', 'Dit is de home pagina') }) }) diff --git a/ui/cypress/support/commands.js b/ui/cypress/support/commands.js index 66ea16e..80d05f5 100644 --- a/ui/cypress/support/commands.js +++ b/ui/cypress/support/commands.js @@ -1,25 +1,57 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) \ No newline at end of file +Cypress.Commands.addAll({ + login(email, password) { + cy.visit('/login') + cy.get('[data-cy="login-email"]').type(email) + cy.get('[data-cy="login-password"]').type(password) + cy.get('[data-cy="login-button"]').click() + }, + logout() { + cy.get('[data-cy="toggle-profile-menu-dropdown"]').click() + cy.get('[data-cy="logout"]').click() + }, + register(name, email, password) { + cy.visit('/register') + cy.get('[data-cy="register-name"]').type(name) + cy.get('[data-cy="register-email"]').type(email) + cy.get('[data-cy="register-password"]').type(password) + cy.get('[data-cy="register-check-password"]').type(password) + cy.get('[data-cy="register-button"]').click() + }, + forgotPassword(email) { + cy.visit('/forgot-password') + cy.get('[data-cy="forgot-password-email"]').type(email) + cy.get('[data-cy="forgot-password-submit"]').click() + }, + navigateToAdminPanel() { + cy.get('[data-cy="toggle-profile-menu-dropdown"]').click() + cy.get('[data-cy="admin-panel"]').click() + }, + navigateToAccount() { + cy.get('[data-cy="toggle-profile-menu-dropdown"]').click() + cy.get('[data-cy="user-account"]').click() + }, + changePassword(currentPassword, newPassword) { + cy.get('[data-cy="open-change-password"]').click() + cy.get('[data-cy="change-password-current"]').type(currentPassword) + cy.get('[data-cy="change-password-new"]').type(newPassword) + cy.get('[data-cy="change-password-confirm"]').type(newPassword) + cy.get('[data-cy="change-password-submit"]').click() + }, + deleteAccount() { + cy.get('[data-cy="delete-account"]').click() + cy.get('[data-cy="confirm-delete-account"]').click() + }, + createUser(email, password, isAdmin) { + cy.get('[data-cy="create-new-user"]').click() + cy.get('[data-cy="new-user-email"]').type(email) + cy.get('[data-cy="new-user-password"]').type(password) + if (isAdmin) { + cy.get('[data-cy="new-user-admin"]').check() + } + cy.get('[data-cy="submit-new-user"]').click() + }, + deleteUser(email) { + cy.get(`[data-cy="delete-user-${email}"]`).click() + cy.get(`[data-cy="delete-user"]`).click() + }, +}) diff --git a/ui/src/api.tsx b/ui/src/api.tsx index ff6a54d..e70c14a 100644 --- a/ui/src/api.tsx +++ b/ui/src/api.tsx @@ -1,8 +1,18 @@ import { TranslationKey, TranslationKeys } from './context/LocaleProvider' import { PaginationResult } from './models/Base' import { UserAttributes } from './models/User' - -interface ErrorData { +import { + WorkspaceAttributes, + WorkspaceInvitationAttributes, + WorkspaceListItemAttributes, +} from './models/Workspace' +import { + BillingStatusAttributes, + PaginatedInvoices, + PaymentMethodAttributes, +} from './models/Billing' + +export interface ErrorData { error: number message: string } @@ -91,15 +101,19 @@ export async function deleteAccount() { } export type RegisterUserData = { + name: string email: string password: string checkPassword: string + invitationToken?: string } export async function register(data: RegisterUserData) { return post('/api/register', { + name: data.name, email: data.email, password: data.password, + ...(data.invitationToken && { invitation_token: data.invitationToken }), }) } @@ -166,37 +180,232 @@ export async function deleteUser(data: DeleteUserData) { return _delete(`/api/user/${data.userID}`) } +// Workspaces + +export async function getWorkspaces(): Promise { + return get('/api/workspaces').then((r) => r.json()) +} + +export type CreateWorkspaceData = { name: string } + +export async function createWorkspace(data: CreateWorkspaceData) { + return post('/api/workspaces', { name: data.name }) +} + +export async function getWorkspace(id: number): Promise { + return get(`/api/workspaces/${id}`).then((r) => r.json()) +} + +export async function updateWorkspace( + id: number, + data: { + name?: string + color?: string | null + context?: string | null + language?: string + } +) { + return put(`/api/workspaces/${id}`, data) +} + +export async function deleteWorkspace(id: number) { + return _delete(`/api/workspaces/${id}`) +} + +export async function inviteMember(workspaceId: number, email: string) { + return post(`/api/workspaces/${workspaceId}/invitations`, { email }) +} + +export async function getInvitations( + workspaceId: number +): Promise { + return get(`/api/workspaces/${workspaceId}/invitations`).then((r) => r.json()) +} + +export async function cancelInvitation( + workspaceId: number, + invitationId: number +) { + return _delete(`/api/workspaces/${workspaceId}/invitations/${invitationId}`) +} + +export async function removeMember(workspaceId: number, userId: number) { + return _delete(`/api/workspaces/${workspaceId}/members/${userId}`) +} + +export async function updateMemberRole( + workspaceId: number, + userId: number, + role: string +) { + return patch(`/api/workspaces/${workspaceId}/members/${userId}`, { role }) +} + +export async function acceptInvitation(token: string) { + return post('/api/invitations/accept', { token }) +} + +export interface InvitationLookupResult { + email: string + workspace_name: string +} + +export async function lookupInvitation( + token: string +): Promise { + const r = await post('/api/invitations/lookup', { token }) + if (!r.ok) return null + return r.json() +} + +// Billing + +export async function getBillingStatus( + workspaceId: number +): Promise { + return get(`/api/workspaces/${workspaceId}/billing`).then((r) => r.json()) +} + +export async function startBillingCheckout( + workspaceId: number +): Promise<{ checkout_url: string }> { + const r = await post(`/api/workspaces/${workspaceId}/billing/checkout`, {}) + const data = await r.json() + if (!r.ok) throw data + return data +} + +export async function updatePaymentMethod( + workspaceId: number +): Promise<{ checkout_url: string }> { + const r = await post( + `/api/workspaces/${workspaceId}/billing/update-payment-method`, + {} + ) + const data = await r.json() + if (!r.ok) throw data + return data +} + +export async function updateBillingSeats( + workspaceId: number, + seats: number +): Promise { + const r = await patch(`/api/workspaces/${workspaceId}/billing/seats`, { + seats, + }) + const data = await r.json() + if (!r.ok) throw data + return data +} + +export async function cancelCheckout(workspaceId: number) { + const r = await _delete(`/api/workspaces/${workspaceId}/billing/checkout`) + const data = await r.json() + if (!r.ok) throw data + return data +} + +export async function cancelSubscription(workspaceId: number) { + return _delete(`/api/workspaces/${workspaceId}/billing/subscription`) +} + +export async function getInvoices( + workspaceId: number, + limit = 5, + offset = 0 +): Promise { + return get( + `/api/workspaces/${workspaceId}/billing/invoices?limit=${limit}&offset=${offset}` + ).then((r) => r.json()) +} + +export async function getPaymentMethod( + workspaceId: number +): Promise { + return get(`/api/workspaces/${workspaceId}/billing/payment-method`).then( + (r) => r.json() + ) +} + +export function getInvoiceDownloadUrl( + workspaceId: number, + invoiceId: number +): string { + return resolveUrl( + `/api/workspaces/${workspaceId}/billing/invoices/${invoiceId}/download` + ) +} + +// HTTP helpers + const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '' -function resolveUrl(url: string): string { +export function resolveUrl(url: string): string { if (API_BASE) { return API_BASE + url.replace(/^\/api/, '') } return url } +async function checkFrozenResponse(response: Response): Promise { + if (response.status === 403) { + try { + const cloned = response.clone() + const json = await cloned.json() + handleFrozenError(json) + } catch { + // ignore parse errors + } + } + return response +} + export async function get(url: string) { return fetch(resolveUrl(url), { method: 'GET', + credentials: 'include', headers: new Headers({ 'Content-Type': 'application/json' }), }) } export async function post(url: string, data: object) { - return fetch(resolveUrl(url), { + const response = await fetch(resolveUrl(url), { method: 'POST', - body: JSON.stringify({ - ...data, - }), + credentials: 'include', + body: JSON.stringify({ ...data }), headers: new Headers({ 'Content-Type': 'application/json' }), }) + return checkFrozenResponse(response) +} + +export async function put(url: string, data: object) { + const response = await fetch(resolveUrl(url), { + method: 'PUT', + credentials: 'include', + body: JSON.stringify({ ...data }), + headers: new Headers({ 'Content-Type': 'application/json' }), + }) + return checkFrozenResponse(response) +} + +export async function patch(url: string, data: object) { + const response = await fetch(resolveUrl(url), { + method: 'PATCH', + credentials: 'include', + body: JSON.stringify({ ...data }), + headers: new Headers({ 'Content-Type': 'application/json' }), + }) + return checkFrozenResponse(response) } export async function _delete(url: string) { - return fetch(resolveUrl(url), { + const response = await fetch(resolveUrl(url), { method: 'DELETE', + credentials: 'include', headers: new Headers({ 'Content-Type': 'application/json' }), }) + return checkFrozenResponse(response) } const errorMessages: Record = { @@ -214,4 +423,31 @@ const errorMessages: Record = { 11: 'twofa_is_already_disabled', 12: 'user_not_found', 13: 'an_unknown_error_occurred', + 14: 'workspace_not_found', + 15: 'not_a_workspace_member', + 16: 'not_workspace_owner_or_admin', + 17: 'already_a_workspace_member', + 18: 'invitation_not_found', + 19: 'cannot_remove_workspace_owner', + 26: 'subscription_not_found', + 27: 'billing_error', + 28: 'invoice_not_found', + 29: 'seat_limit_reached', + 30: 'cannot_leave_last_owner', + 33: 'workspace_frozen', + 35: 'upgrade_disabled', +} + +const WORKSPACE_FROZEN_ERROR = 33 + +export function dispatchFrozenEvent(): void { + window.dispatchEvent(new CustomEvent('workspace-frozen')) +} + +export function handleFrozenError(errorData: ErrorData): boolean { + if (errorData.error === WORKSPACE_FROZEN_ERROR) { + dispatchFrozenEvent() + return true + } + return false } diff --git a/ui/src/components/Alert.tsx b/ui/src/components/Alert.tsx index d1dc8b0..46202ce 100644 --- a/ui/src/components/Alert.tsx +++ b/ui/src/components/Alert.tsx @@ -1,4 +1,4 @@ -import { JSXElement } from 'solid-js' +import { JSXElement, JSX } from 'solid-js' import { clsx } from 'clsx' import { TranslationKey, useLocale } from '../context/LocaleProvider' @@ -8,7 +8,9 @@ interface AlertProps { class?: string } -export function Alert(props: AlertProps): JSXElement { +export function Alert( + props: AlertProps & JSX.HTMLAttributes +): JSXElement { const { t } = useLocale() const translated = () => t((props.message as TranslationKey) ?? '') @@ -25,6 +27,7 @@ export function Alert(props: AlertProps): JSXElement { +

+ {t('upgrade_available_soon')} +

+ + + diff --git a/ui/src/components/ProfileMenu.tsx b/ui/src/components/ProfileMenu.tsx index dc5c200..0c91952 100644 --- a/ui/src/components/ProfileMenu.tsx +++ b/ui/src/components/ProfileMenu.tsx @@ -34,14 +34,18 @@ export function ProfileMenu(): JSXElement { return (