Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions flowbit-backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
AuditLog,
PasswordResetToken,
EmailVerificationToken,
OverrideResetToken,
Ticket,
)

Expand Down Expand Up @@ -315,3 +316,17 @@ def has_add_permission(self, request):

def has_change_permission(self, request, obj=None):
return False


@admin.register(OverrideResetToken)
class OverrideResetTokenAdmin(admin.ModelAdmin):
list_display = ('user', 'selector', 'expires_at', 'used_at', 'created_at')
search_fields = ('user__username', 'user__email', 'selector')
list_filter = ('expires_at', 'used_at', 'created_at')
readonly_fields = ('user', 'selector', 'expires_at', 'used_at', 'created_at')

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
OverflowNotification,
UserNotification,
PasswordResetToken,
OverrideResetToken,
Period,
Ticket,
Transaction,
Expand Down Expand Up @@ -43,6 +44,7 @@ def handle(self, *args, **options):
("periods", Period),
("identifiers", Identifier),
("password_reset_tokens", PasswordResetToken),
("override_reset_tokens", OverrideResetToken),
("audit_logs", AuditLog),
]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Generated by Django 4.2.28 on 2026-05-28 10:03

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0023_supportcase_intake_type_and_more"),
]

operations = [
migrations.AlterModelOptions(
name="supportcase",
options={"ordering": ["-created_at", "-id"]},
),
migrations.CreateModel(
name="OverrideResetToken",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"selector",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("token_hash", models.CharField(max_length=128)),
("expires_at", models.DateTimeField()),
("used_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="override_reset_tokens",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(
fields=["selector"], name="core_overri_selecto_52b4f2_idx"
),
models.Index(
fields=["expires_at"], name="core_overri_expires_def6b0_idx"
),
],
},
),
]
57 changes: 57 additions & 0 deletions flowbit-backend/core/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import secrets
import uuid
import re
from datetime import datetime, time

from django.db import models, transaction
Expand All @@ -14,6 +15,15 @@


DEFAULT_HELPER_NAME = 'system'
OVERRIDE_CODE_PATTERN = re.compile(r'^\d{4}$')


def normalize_override_code(value):
return (value or '').strip()


def is_valid_override_code(value):
return bool(OVERRIDE_CODE_PATTERN.fullmatch(normalize_override_code(value)))


class Period(models.Model):
Expand Down Expand Up @@ -2337,6 +2347,9 @@ def check_master_override_password(self, raw_password):
if self.role != 'admin':
return False

if not is_valid_override_code(raw_password):
return False

stored_value = (self.master_override_password or '').strip()
if not stored_value:
return False
Expand Down Expand Up @@ -2461,3 +2474,47 @@ def issue_for_user(cls, user, expiry_hours=24):
expires_at=timezone.now() + timezone.timedelta(hours=expiry_hours),
)
return verification_token, raw_token


class OverrideResetToken(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='override_reset_tokens')
selector = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
token_hash = models.CharField(max_length=128)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['selector']),
models.Index(fields=['expires_at']),
]

def __str__(self):
return f"Override reset token for {self.user.username}"

@property
def is_active(self):
return self.used_at is None and self.expires_at > timezone.now()

def check_token(self, raw_token):
return self.is_active and check_password(raw_token, self.token_hash)

def mark_used(self, used_at=None):
if used_at is None:
used_at = timezone.now()
self.used_at = used_at
self.save(update_fields=['used_at'])

@classmethod
def issue_for_user(cls, user, expiry_hours=2):
cls.objects.filter(user=user, used_at__isnull=True).update(used_at=timezone.now())

raw_token = secrets.token_urlsafe(32)
override_reset_token = cls.objects.create(
user=user,
token_hash=make_password(raw_token),
expires_at=timezone.now() + timezone.timedelta(hours=expiry_hours),
)
return override_reset_token, raw_token
44 changes: 39 additions & 5 deletions flowbit-backend/core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,30 @@
SupportCase,
SupportMessage,
_from_allocation_basis_amount,
is_valid_override_code,
)


DEFAULT_PERIOD_CLOSE_TIME = time(hour=23, minute=0)
DEFAULT_LEDGER_CLOSE_TIME = time(hour=14, minute=30)


OVERRIDE_CODE_ERROR = "Override code must be exactly 4 digits."


class OverrideCodeField(serializers.CharField):
def __init__(self, *args, allow_blank=False, **kwargs):
super().__init__(*args, allow_blank=allow_blank, trim_whitespace=True, **kwargs)

def to_internal_value(self, data):
value = super().to_internal_value(data)
if value == "" and self.allow_blank:
return value
if not is_valid_override_code(value):
raise serializers.ValidationError(OVERRIDE_CODE_ERROR)
return value.strip()


def _aware_datetime_from_date(value, fallback_time):
naive_datetime = datetime.combine(value, fallback_time)
return timezone.make_aware(naive_datetime, timezone.get_current_timezone())
Expand Down Expand Up @@ -635,7 +652,7 @@ class TicketRefundActionSerializer(serializers.Serializer):
choices=['return_to_tcso', 'refund_spill_over'],
required=False,
)
admin_override_code = serializers.CharField(
admin_override_code = OverrideCodeField(
write_only=True,
required=False,
allow_blank=True,
Expand Down Expand Up @@ -1524,18 +1541,35 @@ def validate_new_password(self, value):
return value


class ForgotOverrideCodeSerializer(serializers.Serializer):
pass


class ResetOverrideCodeConfirmSerializer(serializers.Serializer):
selector = serializers.UUIDField()
token = serializers.CharField(write_only=True)
new_override_code = OverrideCodeField(write_only=True)
confirm_override_code = OverrideCodeField(write_only=True)
account_password = serializers.CharField(write_only=True)

def validate(self, attrs):
if attrs['new_override_code'] != attrs['confirm_override_code']:
raise serializers.ValidationError({'confirm_override_code': 'Override codes do not match.'})
return attrs


class UserRoleUpdateSerializer(serializers.Serializer):
role = serializers.ChoiceField(choices=Profile.ROLE_CHOICES)
admin_override_code = serializers.CharField(write_only=True, required=False, allow_blank=True)
admin_override_code = OverrideCodeField(write_only=True, required=False, allow_blank=True)


class MasterOverridePasswordSerializer(serializers.Serializer):
master_override_password = serializers.CharField(write_only=True, required=False, allow_blank=True)
admin_override_code = serializers.CharField(write_only=True, required=False, allow_blank=True)
master_override_password = OverrideCodeField(write_only=True)
admin_override_code = OverrideCodeField(write_only=True, required=False, allow_blank=True)


class AccountDeletionSerializer(serializers.Serializer):
admin_override_code = serializers.CharField(write_only=True, required=False, allow_blank=True)
admin_override_code = OverrideCodeField(write_only=True, required=False, allow_blank=True)


class ProfileAvatarSerializer(serializers.Serializer):
Expand Down
Loading
Loading