Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6e894dd
Fix typescript strict flag errors (untested)
Mar 15, 2025
9599516
Merge branch 'develop' into 128-restructure-ui-pages-and-modals
Mar 15, 2025
108bf02
Remove duplicate password modal
Mar 16, 2025
f8b0c4b
Merge branch 'develop' into 128-restructure-ui-pages-and-modals
Mar 16, 2025
9d5889d
Add margin to verified e-mail
Mar 16, 2025
5cf7186
WIP: Started on implementing generic submit handler
Mar 16, 2025
da5c962
WIP: Hack field setter
Mar 17, 2025
24ff1b0
Add additional data setter
Mar 17, 2025
7fc4b2c
Cleanup
Mar 18, 2025
4aa1db9
WIP: Share password matcher
Mar 20, 2025
66f5a74
Working towards cleanup
Mar 21, 2025
b1ea1ab
Automatically translate messages in alert component
Mar 23, 2025
04518ac
Fix e2e tests
Mar 23, 2025
216786e
Merge request comments
Mar 24, 2025
23561ce
Fixed lint and added docs
Swopper050 Mar 28, 2025
008a2ff
Really fix lint
Swopper050 Mar 28, 2025
6b77f60
Use data tags and commands for cypress tests
Swopper050 Apr 1, 2025
887d281
Merge remote-tracking branch 'wipsel/128-restructure-ui-pages-and-mod…
Swopper050 Apr 1, 2025
40a4ea0
Merge commit '6b77f60' into 137-refactor-cypress-tests
Swopper050 Apr 1, 2025
f46056c
Refactored Cypress tests
Swopper050 Apr 1, 2025
025218d
Merge branch 'develop' into 137-refactor-cypress-tests
Swopper050 Sep 27, 2025
6d30143
Update template to include workspaces
Swopper050 Apr 26, 2026
38cd293
Merge branch '137-refactor-cypress-tests': clean data-cy selectors
Swopper050 Apr 26, 2026
e8753b1
Fix e2e tests
Swopper050 Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions api/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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}",
Expand All @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions api/app/db/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from app.db.billing import Invoice, WorkspaceSubscription
from app.db.user import User
from app.db.workspace import Workspace, WorkspaceInvitation, WorkspaceMember
154 changes: 154 additions & 0 deletions api/app/db/billing.py
Original file line number Diff line number Diff line change
@@ -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()
17 changes: 17 additions & 0 deletions api/app/db/fields.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions api/app/db/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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)
130 changes: 130 additions & 0 deletions api/app/db/workspace.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading