From 9b19316674b8fabd71d700aafdf14e34fe9605ff Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Thu, 28 May 2026 11:05:02 +0100 Subject: [PATCH 1/3] Enforce 4-digit override codes --- flowbit-backend/core/admin.py | 15 ++ .../commands/purge_operational_data.py | 2 + ..._supportcase_options_overrideresettoken.py | 62 ++++++ flowbit-backend/core/models.py | 57 +++++ flowbit-backend/core/serializers.py | 44 +++- flowbit-backend/core/tests.py | 200 +++++++++++++----- flowbit-backend/core/urls.py | 4 + flowbit-backend/core/views.py | 133 ++++++++++++ flowbit-backend/flowbit_backend/settings.py | 2 + 9 files changed, 457 insertions(+), 62 deletions(-) create mode 100644 flowbit-backend/core/migrations/0024_alter_supportcase_options_overrideresettoken.py diff --git a/flowbit-backend/core/admin.py b/flowbit-backend/core/admin.py index aee07f5..7080891 100644 --- a/flowbit-backend/core/admin.py +++ b/flowbit-backend/core/admin.py @@ -18,6 +18,7 @@ AuditLog, PasswordResetToken, EmailVerificationToken, + OverrideResetToken, Ticket, ) @@ -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 diff --git a/flowbit-backend/core/management/commands/purge_operational_data.py b/flowbit-backend/core/management/commands/purge_operational_data.py index cb82b90..65aba13 100644 --- a/flowbit-backend/core/management/commands/purge_operational_data.py +++ b/flowbit-backend/core/management/commands/purge_operational_data.py @@ -12,6 +12,7 @@ OverflowNotification, UserNotification, PasswordResetToken, + OverrideResetToken, Period, Ticket, Transaction, @@ -43,6 +44,7 @@ def handle(self, *args, **options): ("periods", Period), ("identifiers", Identifier), ("password_reset_tokens", PasswordResetToken), + ("override_reset_tokens", OverrideResetToken), ("audit_logs", AuditLog), ] diff --git a/flowbit-backend/core/migrations/0024_alter_supportcase_options_overrideresettoken.py b/flowbit-backend/core/migrations/0024_alter_supportcase_options_overrideresettoken.py new file mode 100644 index 0000000..b4fd5bc --- /dev/null +++ b/flowbit-backend/core/migrations/0024_alter_supportcase_options_overrideresettoken.py @@ -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" + ), + ], + }, + ), + ] diff --git a/flowbit-backend/core/models.py b/flowbit-backend/core/models.py index 48ec4cd..939f1fe 100644 --- a/flowbit-backend/core/models.py +++ b/flowbit-backend/core/models.py @@ -1,5 +1,6 @@ import secrets import uuid +import re from datetime import datetime, time from django.db import models, transaction @@ -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): @@ -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 @@ -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 diff --git a/flowbit-backend/core/serializers.py b/flowbit-backend/core/serializers.py index beaa960..781f73d 100644 --- a/flowbit-backend/core/serializers.py +++ b/flowbit-backend/core/serializers.py @@ -31,6 +31,7 @@ SupportCase, SupportMessage, _from_allocation_basis_amount, + is_valid_override_code, ) @@ -38,6 +39,22 @@ 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()) @@ -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, @@ -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): diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py index c61f114..13513cb 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -30,6 +30,7 @@ AuditLog, PasswordResetToken, EmailVerificationToken, + OverrideResetToken, Profile, Collaborator, Ticket, @@ -144,6 +145,7 @@ def test_purge_operational_data_keeps_users_and_profiles(self): message='Test notification', ) PasswordResetToken.issue_for_user(regular_user, expiry_hours=1) + OverrideResetToken.issue_for_user(admin_user, expiry_hours=1) Collaborator.objects.create( owner=admin_user, username='purge_helper', @@ -167,6 +169,7 @@ def test_purge_operational_data_keeps_users_and_profiles(self): self.assertFalse(IdentifierCapacityAdjustment.objects.exists()) self.assertFalse(Collaborator.objects.exists()) self.assertFalse(PasswordResetToken.objects.exists()) + self.assertFalse(OverrideResetToken.objects.exists()) self.assertFalse(AuditLog.objects.exists()) self.assertFalse(Identifier.objects.exists()) self.assertIn('Deleted', out.getvalue()) @@ -700,14 +703,14 @@ def test_regular_user_can_delete_account_with_admin_override_code(self): email='account-admin@example.com', ) admin_user.profile.role = 'admin' - admin_user.profile.set_master_override_password('override-123') + admin_user.profile.set_master_override_password('1234') admin_user.profile.save(update_fields=['role', 'master_override_password', 'updated_at']) token = Token.objects.create(user=self.user) self.client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}') response = self.client.delete('/api/auth/me/', { - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', }, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -814,12 +817,12 @@ def test_google_login_rejects_unverified_email(self, mock_verify_google_id_token def test_login_accepts_master_override_password(self): self.user.profile.role = 'admin' self.user.profile.save(update_fields=['role', 'updated_at']) - self.user.profile.set_master_override_password('override-456') + self.user.profile.set_master_override_password('4567') self.user.profile.save(update_fields=['master_override_password', 'updated_at']) response = self.client.post('/api/auth/login/', { 'username': 'auth_user', - 'password': 'override-456', + 'password': '4567', }, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -827,12 +830,12 @@ def test_login_accepts_master_override_password(self): self.assertTrue(AuditLog.objects.filter(action='auth.login_override', target_id=self.user.id).exists()) def test_login_rejects_master_override_for_non_admin_user(self): - self.user.profile.set_master_override_password('override-456') + self.user.profile.set_master_override_password('4567') self.user.profile.save(update_fields=['master_override_password', 'updated_at']) response = self.client.post('/api/auth/login/', { 'username': 'auth_user', - 'password': 'override-456', + 'password': '4567', }, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -848,8 +851,67 @@ def test_forgot_password_sends_reset_email_for_existing_user(self): self.assertEqual(mail.outbox[0].to, ['auth@example.com']) self.assertIn('Selector:', mail.outbox[0].body) self.assertIn('Token:', mail.outbox[0].body) + + def test_forgot_override_code_sends_reset_email_for_admin(self): + self.user.profile.role = 'admin' + self.user.profile.set_master_override_password('4567') + self.user.profile.save(update_fields=['role', 'master_override_password', 'updated_at']) + self.client.force_authenticate(user=self.user) + + response = self.client.post('/api/auth/forgot-override-code/', {}, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, ['auth@example.com']) + self.assertIn('FlowBit override code reset request', mail.outbox[0].body) + self.assertTrue(OverrideResetToken.objects.filter(user=self.user).exists()) + + def test_forgot_override_code_requires_admin_account(self): + self.client.force_authenticate(user=self.user) + + response = self.client.post('/api/auth/forgot-override-code/', {}, format='json') + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.data['detail'], 'Only admin accounts can reset override codes.') + + def test_reset_override_code_requires_correct_account_password(self): + self.user.profile.role = 'admin' + self.user.profile.set_master_override_password('4567') + self.user.profile.save(update_fields=['role', 'master_override_password', 'updated_at']) + override_reset_token, raw_token = OverrideResetToken.issue_for_user(self.user, expiry_hours=1) + + response = self.client.post('/api/auth/reset-override-code/', { + 'selector': str(override_reset_token.selector), + 'token': raw_token, + 'new_override_code': '1234', + 'confirm_override_code': '1234', + 'account_password': 'wrong-password', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['detail'], 'Account password is incorrect.') + + def test_reset_override_code_completes_with_valid_token(self): + self.user.profile.role = 'admin' + self.user.profile.set_master_override_password('4567') + self.user.profile.save(update_fields=['role', 'master_override_password', 'updated_at']) + override_reset_token, raw_token = OverrideResetToken.issue_for_user(self.user, expiry_hours=1) + + response = self.client.post('/api/auth/reset-override-code/', { + 'selector': str(override_reset_token.selector), + 'token': raw_token, + 'new_override_code': '1234', + 'confirm_override_code': '1234', + 'account_password': 'password123', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.user.refresh_from_db() + self.assertTrue(self.user.profile.check_master_override_password('1234')) + override_reset_token.refresh_from_db() + self.assertIsNotNone(override_reset_token.used_at) self.assertTrue( - AuditLog.objects.filter(action='auth.password_reset_requested', target_id=self.user.id).exists() + AuditLog.objects.filter(action='auth.override_reset_completed', target_id=self.user.id).exists() ) def test_forgot_password_returns_generic_message_for_unknown_email(self): @@ -977,7 +1039,7 @@ def setUp(self): password='password123', ) self.admin_user.profile.role = 'admin' - self.admin_user.profile.set_master_override_password('override-123') + self.admin_user.profile.set_master_override_password('1234') self.admin_user.profile.save(update_fields=['role', 'master_override_password', 'updated_at']) self.regular_user = User.objects.create_user( @@ -1014,7 +1076,7 @@ def test_regular_user_cannot_create_period(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_regular_user_can_create_period_with_admin_override_code(self): - self.admin_user.profile.set_master_override_password('override-123') + self.admin_user.profile.set_master_override_password('1234') self.admin_user.profile.save(update_fields=['master_override_password', 'updated_at']) self.client.force_authenticate(user=self.regular_user) @@ -1023,7 +1085,7 @@ def test_regular_user_can_create_period_with_admin_override_code(self): 'start_date': '2028-01-01', 'end_date': '2028-01-31', 'is_open': False, - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', }, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -1033,13 +1095,13 @@ def test_regular_user_can_create_period_with_admin_override_code(self): self.assertTrue(admin_audit.changes['admin_override_used']) def test_regular_user_can_close_ledger_with_admin_override_code(self): - self.admin_user.profile.set_master_override_password('override-123') + self.admin_user.profile.set_master_override_password('1234') self.admin_user.profile.save(update_fields=['master_override_password', 'updated_at']) self.client.force_authenticate(user=self.regular_user) response = self.client.post( f'/api/ledgers/{self.ledger.id}/close/', - {'admin_override_code': 'override-123'}, + {'admin_override_code': '1234'}, format='json' ) @@ -1048,7 +1110,7 @@ def test_regular_user_can_close_ledger_with_admin_override_code(self): self.assertFalse(self.ledger.is_active) def test_regular_user_can_reopen_closed_ledger_with_admin_override_code(self): - self.admin_user.profile.set_master_override_password('override-123') + self.admin_user.profile.set_master_override_password('1234') self.admin_user.profile.save(update_fields=['master_override_password', 'updated_at']) self.ledger.close() self.client.force_authenticate(user=self.regular_user) @@ -1056,7 +1118,7 @@ def test_regular_user_can_reopen_closed_ledger_with_admin_override_code(self): response = self.client.post( f'/api/ledgers/{self.ledger.id}/reopen/', { - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'end_date': '2027-01-20', 'close_time': '18:30', }, @@ -1173,7 +1235,7 @@ def test_admin_can_change_user_role(self): response = self.client.post( f'/api/users/{self.regular_user.id}/set-role/', - {'role': 'admin', 'admin_override_code': 'override-123'}, + {'role': 'admin', 'admin_override_code': '1234'}, format='json' ) @@ -1192,7 +1254,7 @@ def test_admin_cannot_downgrade_own_account(self): response = self.client.post( f'/api/users/{self.admin_user.id}/set-role/', - {'role': 'user', 'admin_override_code': 'override-123'}, + {'role': 'user', 'admin_override_code': '1234'}, format='json' ) @@ -1205,13 +1267,13 @@ def test_admin_can_set_master_override_password(self): self.client.force_authenticate(user=self.admin_user) response = self.client.post( f'/api/users/{self.admin_user.id}/set-master-override-password/', - {'master_override_password': 'override-999', 'admin_override_code': 'override-123'}, + {'master_override_password': '9999', 'admin_override_code': '1234'}, format='json' ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.admin_user.refresh_from_db() - self.assertTrue(self.admin_user.profile.check_master_override_password('override-999')) + self.assertTrue(self.admin_user.profile.check_master_override_password('9999')) self.assertTrue( UserNotification.objects.filter( recipient=self.admin_user, @@ -1226,20 +1288,32 @@ def test_admin_can_set_initial_master_override_password_without_existing_overrid response = self.client.post( f'/api/users/{self.admin_user.id}/set-master-override-password/', - {'master_override_password': 'first-override'}, + {'master_override_password': '2468'}, format='json' ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.admin_user.refresh_from_db() - self.assertTrue(self.admin_user.profile.check_master_override_password('first-override')) + self.assertTrue(self.admin_user.profile.check_master_override_password('2468')) + + def test_admin_cannot_set_non_numeric_override_password(self): + self.client.force_authenticate(user=self.admin_user) + + response = self.client.post( + f'/api/users/{self.admin_user.id}/set-master-override-password/', + {'master_override_password': '12a4', 'admin_override_code': '1234'}, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['master_override_password'][0], 'Override code must be exactly 4 digits.') def test_admin_can_delete_user_account(self): self.client.force_authenticate(user=self.admin_user) response = self.client.delete( f'/api/users/{self.regular_user.id}/', - {'admin_override_code': 'override-123'}, + {'admin_override_code': '1234'}, format='json', ) @@ -1252,7 +1326,7 @@ def test_admin_cannot_set_master_override_password_for_non_admin_user(self): response = self.client.post( f'/api/users/{self.regular_user.id}/set-master-override-password/', - {'master_override_password': 'override-123', 'admin_override_code': 'override-123'}, + {'master_override_password': '1234', 'admin_override_code': '1234'}, format='json' ) @@ -1283,12 +1357,12 @@ def test_admin_cannot_change_user_role_without_override_code(self): def test_admin_cannot_set_master_override_without_override_code(self): self.client.force_authenticate(user=self.admin_user) - self.admin_user.profile.set_master_override_password('existing-override') + self.admin_user.profile.set_master_override_password('5678') self.admin_user.profile.save(update_fields=['master_override_password', 'updated_at']) response = self.client.post( f'/api/users/{self.admin_user.id}/set-master-override-password/', - {'master_override_password': 'override-999'}, + {'master_override_password': '9999'}, format='json' ) @@ -1297,12 +1371,12 @@ def test_admin_cannot_set_master_override_without_override_code(self): def test_admin_cannot_set_master_override_with_incorrect_override_code(self): self.client.force_authenticate(user=self.admin_user) - self.admin_user.profile.set_master_override_password('existing-override') + self.admin_user.profile.set_master_override_password('5678') self.admin_user.profile.save(update_fields=['master_override_password', 'updated_at']) response = self.client.post( f'/api/users/{self.admin_user.id}/set-master-override-password/', - {'master_override_password': 'override-999', 'admin_override_code': 'wrong-code'}, + {'master_override_password': '9999', 'admin_override_code': '0000'}, format='json' ) @@ -1313,12 +1387,12 @@ def test_admin_cannot_set_other_admin_override_password(self): self.client.force_authenticate(user=self.admin_user) other_admin = User.objects.create_user(username='second_admin_user', password='password123') other_admin.profile.role = 'admin' - other_admin.profile.set_master_override_password('second-override') + other_admin.profile.set_master_override_password('6789') other_admin.profile.save(update_fields=['role', 'master_override_password', 'updated_at']) response = self.client.post( f'/api/users/{other_admin.id}/set-master-override-password/', - {'master_override_password': 'override-999', 'admin_override_code': 'override-123'}, + {'master_override_password': '9999', 'admin_override_code': '1234'}, format='json' ) @@ -1332,6 +1406,18 @@ def test_admin_cannot_delete_user_without_override_code(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data['detail'], 'Admin override code is required for this action.') + + def test_admin_cannot_delete_user_with_invalid_override_code_format(self): + self.client.force_authenticate(user=self.admin_user) + + response = self.client.delete( + f'/api/users/{self.regular_user.id}/', + {'admin_override_code': '12a4'}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['detail'], 'Override code must be exactly 4 digits.') self.assertTrue(User.objects.filter(pk=self.regular_user.pk).exists()) def test_admin_cannot_delete_user_with_incorrect_override_code(self): @@ -1339,7 +1425,7 @@ def test_admin_cannot_delete_user_with_incorrect_override_code(self): response = self.client.delete( f'/api/users/{self.regular_user.id}/', - {'admin_override_code': 'wrong-code'}, + {'admin_override_code': '0000'}, format='json', ) @@ -1436,7 +1522,7 @@ def setUp(self): password='password123', ) self.admin_user.profile.role = 'admin' - self.admin_user.profile.set_master_override_password('override-123') + self.admin_user.profile.set_master_override_password('1234') self.admin_user.profile.save(update_fields=['role', 'master_override_password', 'updated_at']) self.user_one = User.objects.create_user( @@ -1471,7 +1557,7 @@ def test_regular_user_cannot_create_period_even_with_override_code(self): 'name': 'Blocked Period', 'start_date': '2028-01-01', 'end_date': '2028-01-31', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', }, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -2729,7 +2815,7 @@ def setUp(self): password='password123', ) self.approver.profile.role = 'admin' - self.approver.profile.set_master_override_password('override-123') + self.approver.profile.set_master_override_password('1234') self.approver.profile.save(update_fields=['role', 'master_override_password', 'updated_at']) self.other_user = User.objects.create_user( username='other_user', @@ -2874,7 +2960,7 @@ def test_ticket_list_can_search_identifier_across_active_period(self): def test_ticket_list_can_filter_refunded_tickets(self): refund_response = self.client.post( f'/api/tickets/{self.active_ticket.ticket_number}/refund/', - {'action': 'refund_ticket', 'admin_override_code': 'override-123'}, + {'action': 'refund_ticket', 'admin_override_code': '1234'}, format='json', ) self.assertEqual(refund_response.status_code, status.HTTP_200_OK) @@ -3319,7 +3405,7 @@ def test_ticket_transaction_refund_can_sync_repeat_ticket_template(self): f'/api/tickets/{generated_ticket.ticket_number}/refund/', { 'action': 'refund_transaction', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'transaction_id': generated_transaction.id, 'sync_repeat_ticket': True, }, @@ -3363,7 +3449,7 @@ def test_overflow_refund_can_sync_repeat_ticket_template_amount(self): f'/api/overflows/{overflow.id}/resolve/', { 'action': 'refund_overflow_only', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'sync_repeat_ticket': True, }, format='json', @@ -3974,7 +4060,7 @@ def test_ticket_detail_returns_receipt_transactions_for_current_user(self): def test_ticket_refund_can_succeed_without_spill_over(self): response = self.client.post( f'/api/tickets/{self.active_ticket.ticket_number}/refund/', - {'action': 'refund_ticket', 'admin_override_code': 'override-123'}, + {'action': 'refund_ticket', 'admin_override_code': '1234'}, format='json', ) @@ -3997,7 +4083,7 @@ def test_ticket_refund_requires_admin_override_code_for_admin_user(self): def test_ticket_refund_rejects_incorrect_admin_override_code_for_admin_user(self): response = self.client.post( f'/api/tickets/{self.active_ticket.ticket_number}/refund/', - {'action': 'refund_ticket', 'admin_override_code': 'wrong-code'}, + {'action': 'refund_ticket', 'admin_override_code': '0000'}, format='json', ) @@ -4053,7 +4139,7 @@ def test_ticket_transaction_refund_can_succeed_without_spill_over(self): f'/api/tickets/{self.active_ticket.ticket_number}/refund/', { 'action': 'refund_transaction', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'transaction_id': self.active_transaction.id, }, format='json', @@ -4086,7 +4172,7 @@ def test_ticket_total_amount_updates_after_partial_transaction_refund(self): f'/api/tickets/{self.active_ticket.ticket_number}/refund/', { 'action': 'refund_transaction', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'transaction_id': second_transaction.id, }, format='json', @@ -4104,7 +4190,7 @@ def test_ticket_total_amount_updates_after_overflow_only_refund(self): response = self.client.post( f'/api/overflows/{overflow.id}/resolve/', - {'action': 'refund_overflow_only', 'admin_override_code': 'override-123'}, + {'action': 'refund_overflow_only', 'admin_override_code': '1234'}, format='json', ) @@ -4143,7 +4229,7 @@ def test_ticket_refund_with_cso_can_change_back_to_tcso_without_reducing_total(s f'/api/tickets/{refund_ticket.ticket_number}/refund/', { 'action': 'refund_ticket', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'cso_refund_mode': 'return_to_tcso', }, format='json', @@ -4191,7 +4277,7 @@ def test_return_to_tcso_restores_transaction_total_if_old_bug_already_reduced_it f'/api/tickets/{refund_ticket.ticket_number}/refund/', { 'action': 'refund_ticket', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'cso_refund_mode': 'return_to_tcso', }, format='json', @@ -4231,7 +4317,7 @@ def test_ticket_transaction_refund_with_cso_can_refund_spill_over_into_overkill( { 'action': 'refund_transaction', 'transaction_id': refund_transaction.id, - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'cso_refund_mode': 'refund_spill_over', }, format='json', @@ -4257,14 +4343,14 @@ def test_ticket_refunds_are_blocked_after_period_pre_close(self): refund_ticket_response = self.client.post( f'/api/tickets/{self.active_ticket.ticket_number}/refund/', - {'action': 'refund_ticket', 'admin_override_code': 'override-123'}, + {'action': 'refund_ticket', 'admin_override_code': '1234'}, format='json', ) refund_transaction_response = self.client.post( f'/api/tickets/{self.active_ticket.ticket_number}/refund/', { 'action': 'refund_transaction', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'transaction_id': self.active_transaction.id, }, format='json', @@ -4285,7 +4371,7 @@ def test_overflow_refunds_are_blocked_after_period_pre_close(self): response = self.client.post( f'/api/overflows/{overflow.id}/resolve/', - {'action': 'refund_overflow_only', 'admin_override_code': 'override-123'}, + {'action': 'refund_overflow_only', 'admin_override_code': '1234'}, format='json', ) @@ -4311,7 +4397,7 @@ def test_ticket_total_amount_converts_refunded_spill_over_from_basis_amount(self response = self.client.post( f'/api/overflows/{overflow.id}/resolve/', - {'action': 'refund_overflow_only', 'admin_override_code': 'override-123'}, + {'action': 'refund_overflow_only', 'admin_override_code': '1234'}, format='json', ) @@ -4322,7 +4408,7 @@ def test_ticket_total_amount_converts_refunded_spill_over_from_basis_amount(self def test_fully_refunded_ticket_still_appears_in_active_period_history(self): response = self.client.post( f'/api/tickets/{self.active_ticket.ticket_number}/refund/', - {'action': 'refund_ticket', 'admin_override_code': 'override-123'}, + {'action': 'refund_ticket', 'admin_override_code': '1234'}, format='json', ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -4345,7 +4431,7 @@ def test_ticket_receipt_pdf_export_returns_pdf_for_current_user(self): def test_ticket_refund_audit_includes_ticket_summary(self): response = self.client.post( f'/api/tickets/{self.active_ticket.ticket_number}/refund/', - {'action': 'refund_ticket', 'admin_override_code': 'override-123'}, + {'action': 'refund_ticket', 'admin_override_code': '1234'}, format='json', ) @@ -4616,7 +4702,7 @@ def test_returning_cso_overflow_moves_it_back_to_tcso_without_reducing_total(sel f'/api/overflows/{overflow.id}/resolve/', { 'action': 'refund_overflow_only', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'cso_refund_mode': 'return_to_tcso', }, format='json', @@ -4662,7 +4748,7 @@ def test_refunding_cso_spill_over_moves_it_to_overkill_and_reduces_total(self): f'/api/overflows/{overflow.id}/resolve/', { 'action': 'refund_overflow_only', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'cso_refund_mode': 'refund_spill_over', }, format='json', @@ -4703,7 +4789,7 @@ def test_reapproving_returned_cso_restores_active_total(self): f'/api/overflows/{overflow.id}/resolve/', { 'action': 'refund_overflow_only', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'cso_refund_mode': 'return_to_tcso', }, format='json', @@ -4749,7 +4835,7 @@ def test_refund_transaction_on_cso_can_change_back_to_tcso(self): f'/api/overflows/{overflow.id}/resolve/', { 'action': 'refund_transaction', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'cso_refund_mode': 'return_to_tcso', }, format='json', @@ -4783,7 +4869,7 @@ def test_refund_ticket_on_cso_can_refund_spill_over_into_overkill(self): f'/api/overflows/{overflow.id}/resolve/', { 'action': 'refund_ticket', - 'admin_override_code': 'override-123', + 'admin_override_code': '1234', 'cso_refund_mode': 'refund_spill_over', }, format='json', @@ -4832,7 +4918,7 @@ def test_returning_overkill_removes_it_and_clears_reserve_capacity(self): return_response = self.client.post( f'/api/overflows/{overkill.id}/resolve/', - {'action': 'refund_overflow_only', 'admin_override_code': 'override-123'}, + {'action': 'refund_overflow_only', 'admin_override_code': '1234'}, format='json', ) @@ -4889,7 +4975,7 @@ def test_returning_reserve_consumed_cso_merges_back_into_overkill(self): return_response = self.client.post( f'/api/overflows/{consumed_cso.id}/resolve/', - {'action': 'refund_overflow_only', 'admin_override_code': 'override-123'}, + {'action': 'refund_overflow_only', 'admin_override_code': '1234'}, format='json', ) @@ -4959,7 +5045,7 @@ def test_refunding_ticket_with_reserve_consumed_cso_restores_overkill_balance(se refund_response = self.client.post( f'/api/tickets/{consume_ticket.ticket_number}/refund/', - {'action': 'refund_ticket', 'admin_override_code': 'override-123'}, + {'action': 'refund_ticket', 'admin_override_code': '1234'}, format='json', ) diff --git a/flowbit-backend/core/urls.py b/flowbit-backend/core/urls.py index 4c3a5aa..8432206 100644 --- a/flowbit-backend/core/urls.py +++ b/flowbit-backend/core/urls.py @@ -26,9 +26,11 @@ ProfileAvatarView, ChangePasswordView, ForgotPasswordView, + ForgotOverrideCodeView, VerifyEmailView, ResendVerificationView, ResetPasswordConfirmView, + ResetOverrideCodeConfirmView, PublicLoginHelpCaseCreateView, TicketListView, TicketDetailView, @@ -64,9 +66,11 @@ path('auth/avatar/', ProfileAvatarView.as_view(), name='auth-avatar'), path('auth/change-password/', ChangePasswordView.as_view(), name='auth-change-password'), path('auth/forgot-password/', ForgotPasswordView.as_view(), name='auth-forgot-password'), + path('auth/forgot-override-code/', ForgotOverrideCodeView.as_view(), name='auth-forgot-override-code'), path('auth/verify-email/', VerifyEmailView.as_view(), name='auth-verify-email'), path('auth/resend-verification/', ResendVerificationView.as_view(), name='auth-resend-verification'), path('auth/reset-password/', ResetPasswordConfirmView.as_view(), name='auth-reset-password'), + path('auth/reset-override-code/', ResetOverrideCodeConfirmView.as_view(), name='auth-reset-override-code'), path('reports/dashboard/', DashboardReportView.as_view(), name='report-dashboard'), path('reports/dashboard/hot-numbers/', DashboardHotNumberReportView.as_view(), name='report-dashboard-hot-numbers'), diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py index 2649095..b8b29c7 100644 --- a/flowbit-backend/core/views.py +++ b/flowbit-backend/core/views.py @@ -48,6 +48,7 @@ Profile, PasswordResetToken, EmailVerificationToken, + OverrideResetToken, Collaborator, Ticket, RepeatTicket, @@ -63,6 +64,7 @@ preview_transaction_allocation, refund_overflow, refund_transactions, + is_valid_override_code, ) from .audit import record_audit_log, serialize_audit_value, snapshot_instance from .notification_realtime import ( @@ -110,9 +112,11 @@ AccountDeletionSerializer, ProfileAvatarSerializer, ForgotPasswordSerializer, + ForgotOverrideCodeSerializer, EmailVerificationConfirmSerializer, ResendVerificationSerializer, ResetPasswordConfirmSerializer, + ResetOverrideCodeConfirmSerializer, CollaboratorManageSerializer, UserRoleUpdateSerializer, MasterOverridePasswordSerializer, @@ -244,6 +248,25 @@ def build_email_verification_email_body(verification_token, raw_token): return "\n".join(body_lines) +def build_override_reset_email_body(override_reset_token, raw_token): + frontend_url = getattr(settings, 'FRONTEND_OVERRIDE_RESET_URL', '').strip() + body_lines = [ + "FlowBit override code reset request", + "", + "Use this link to reset your 4-digit admin override code.", + "", + f"Selector: {override_reset_token.selector}", + f"Token: {raw_token}", + f"Expires At: {timezone.localtime(override_reset_token.expires_at).isoformat()}", + ] + if frontend_url: + body_lines.extend([ + "", + f"Reset URL: {frontend_url}?selector={override_reset_token.selector}&token={raw_token}", + ]) + return "\n".join(body_lines) + + class AuthEmailDeliveryError(Exception): pass @@ -289,6 +312,26 @@ def issue_and_send_email_verification(*, request, user): return verification_token +def issue_and_send_override_reset(*, request, user): + expiry_hours = getattr(settings, 'OVERRIDE_RESET_TOKEN_EXPIRY_HOURS', 2) + override_reset_token, raw_token = OverrideResetToken.issue_for_user(user, expiry_hours=expiry_hours) + send_auth_email( + request=request, + user=user, + subject='FlowBit override code reset', + message=build_override_reset_email_body(override_reset_token, raw_token), + audit_action='override_reset', + ) + record_audit_log( + request, + 'auth.override_reset_requested', + target=user, + details=f"Override reset requested for '{user.username}'", + changes={'email': user.email, 'selector': str(override_reset_token.selector)}, + ) + return override_reset_token + + def get_email_verification_resend_wait_seconds(user): cooldown_seconds = max(getattr(settings, 'EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS', 60), 0) if cooldown_seconds == 0: @@ -4872,6 +4915,44 @@ def post(self, request): ) +class ForgotOverrideCodeView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = ForgotOverrideCodeSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + + profile, _ = Profile.objects.get_or_create(user=request.user) + if profile.role != 'admin': + return Response( + {'detail': 'Only admin accounts can reset override codes.'}, + status=status.HTTP_403_FORBIDDEN, + ) + if not profile.master_override_password: + return Response( + {'detail': 'No override code is configured for this admin account yet.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not request.user.email: + return Response( + {'detail': 'Add an email address to your account before resetting your override code.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + issue_and_send_override_reset(request=request, user=request.user) + except AuthEmailDeliveryError: + return Response( + {'detail': 'We could not send the override reset email right now. Please try again shortly.'}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + return Response( + {'message': 'If the email exists, an override reset message has been sent.'}, + status=status.HTTP_200_OK, + ) + + class VerifyEmailView(APIView): permission_classes = [AllowAny] @@ -4987,6 +5068,53 @@ def post(self, request): ) +class ResetOverrideCodeConfirmView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = ResetOverrideCodeConfirmSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + override_reset_token = OverrideResetToken.objects.filter( + selector=serializer.validated_data['selector'] + ).select_related('user').first() + if override_reset_token is None or not override_reset_token.check_token(serializer.validated_data['token']): + return Response( + {'detail': 'Override reset token is invalid or expired.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = override_reset_token.user + profile, _ = Profile.objects.get_or_create(user=user) + if profile.role != 'admin': + return Response( + {'detail': 'Only admin accounts can reset override codes.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not user.check_password(serializer.validated_data['account_password']): + return Response( + {'detail': 'Account password is incorrect.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + profile.set_master_override_password(serializer.validated_data['new_override_code']) + profile.save(update_fields=['master_override_password', 'updated_at']) + override_reset_token.mark_used() + + record_audit_log( + request, + 'auth.override_reset_completed', + target=user, + details=f"Override reset completed for '{user.username}'", + changes={'selector': str(override_reset_token.selector)}, + ) + + return Response( + {'message': 'Override code reset successfully.'}, + status=status.HTTP_200_OK, + ) + + class RegisterView(APIView): permission_classes = [AllowAny] @@ -5348,6 +5476,11 @@ def _require_admin_override(self, request, allow_initial_setup_profile=None): {'detail': 'Admin override code is required for this action.'}, status=status.HTTP_400_BAD_REQUEST, ) + if not is_valid_override_code(raw_code): + return Response( + {'detail': 'Override code must be exactly 4 digits.'}, + status=status.HTTP_400_BAD_REQUEST, + ) override_profile = get_valid_admin_override_profile(raw_code) if override_profile is None: return Response( diff --git a/flowbit-backend/flowbit_backend/settings.py b/flowbit-backend/flowbit_backend/settings.py index 56e2ba9..126c639 100644 --- a/flowbit-backend/flowbit_backend/settings.py +++ b/flowbit-backend/flowbit_backend/settings.py @@ -198,6 +198,8 @@ PASSWORD_RESET_TOKEN_EXPIRY_HOURS = config('PASSWORD_RESET_TOKEN_EXPIRY_HOURS', cast=int, default=2) FRONTEND_EMAIL_VERIFICATION_URL = config('FRONTEND_EMAIL_VERIFICATION_URL', default='') EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS = config('EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', cast=int, default=24) +FRONTEND_OVERRIDE_RESET_URL = config('FRONTEND_OVERRIDE_RESET_URL', default='') +OVERRIDE_RESET_TOKEN_EXPIRY_HOURS = config('OVERRIDE_RESET_TOKEN_EXPIRY_HOURS', cast=int, default=2) EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS = config('EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS', cast=int, default=60) SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') From 9b6d808c5e932b28884f31edd96c98f4f310930a Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Thu, 28 May 2026 11:11:09 +0100 Subject: [PATCH 2/3] Add override code recovery UI --- .../src/app/reset-override-code/page.tsx | 14 ++ .../components/admin/admin-confirm-modal.tsx | 10 +- .../admin/admin-override-codes-page.tsx | 63 +++++-- .../components/admin/override-code-input.tsx | 114 ++++++++++++ .../src/components/auth/login-form-card.tsx | 77 +++++--- .../auth/reset-override-code-form-card.tsx | 173 ++++++++++++++++++ .../auth/reset-override-code-page.tsx | 15 ++ .../profile/profile-danger-zone-card.tsx | 13 +- .../tickets/ticket-refund-modal.tsx | 9 +- flowbit-frontend/src/lib/auth-client.ts | 28 +++ flowbit-frontend/src/proxy.ts | 10 +- 11 files changed, 459 insertions(+), 67 deletions(-) create mode 100644 flowbit-frontend/src/app/reset-override-code/page.tsx create mode 100644 flowbit-frontend/src/components/admin/override-code-input.tsx create mode 100644 flowbit-frontend/src/components/auth/reset-override-code-form-card.tsx create mode 100644 flowbit-frontend/src/components/auth/reset-override-code-page.tsx diff --git a/flowbit-frontend/src/app/reset-override-code/page.tsx b/flowbit-frontend/src/app/reset-override-code/page.tsx new file mode 100644 index 0000000..7ee9fa4 --- /dev/null +++ b/flowbit-frontend/src/app/reset-override-code/page.tsx @@ -0,0 +1,14 @@ +import { ResetOverrideCodePage } from "@/components/auth/reset-override-code-page"; + +type ResetOverrideCodeRouteProps = { + searchParams: Promise<{ + selector?: string; + token?: string; + }>; +}; + +export default async function ResetOverrideCodeRoute({ searchParams }: ResetOverrideCodeRouteProps) { + const params = await searchParams; + + return ; +} diff --git a/flowbit-frontend/src/components/admin/admin-confirm-modal.tsx b/flowbit-frontend/src/components/admin/admin-confirm-modal.tsx index 83e2643..a882bfc 100644 --- a/flowbit-frontend/src/components/admin/admin-confirm-modal.tsx +++ b/flowbit-frontend/src/components/admin/admin-confirm-modal.tsx @@ -1,8 +1,8 @@ "use client"; import type { ReactNode } from "react"; +import { OverrideCodeInput } from "@/components/admin/override-code-input"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; type AdminConfirmModalProps = { open: boolean; @@ -55,13 +55,7 @@ export function AdminConfirmModal({ {showCodeInput ? ( ) : null} diff --git a/flowbit-frontend/src/components/admin/admin-override-codes-page.tsx b/flowbit-frontend/src/components/admin/admin-override-codes-page.tsx index 2c78abf..dbec3b1 100644 --- a/flowbit-frontend/src/components/admin/admin-override-codes-page.tsx +++ b/flowbit-frontend/src/components/admin/admin-override-codes-page.tsx @@ -4,11 +4,11 @@ import { useEffect, useState } from "react"; import { WorkspaceShell } from "@/components/app/workspace-shell"; import { AdminAccessGuard } from "@/components/admin/admin-access-guard"; import { AdminConfirmModal } from "@/components/admin/admin-confirm-modal"; +import { OverrideCodeInput } from "@/components/admin/override-code-input"; import { AdminPageHeader } from "@/components/admin/admin-page-header"; import { AdminActionToast } from "@/components/admin/admin-action-toast"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { fetchCurrentUser, type AuthUser } from "@/lib/auth-client"; +import { fetchCurrentUser, requestOverrideCodeReset, type AuthUser } from "@/lib/auth-client"; import { updateManagedUserOverride } from "@/lib/admin-client"; type ToastState = { @@ -21,6 +21,7 @@ export function AdminOverrideCodesPage() { const [oldOverrideCode, setOldOverrideCode] = useState(""); const [newOverrideCode, setNewOverrideCode] = useState(""); const [pending, setPending] = useState(false); + const [isSendingReset, setIsSendingReset] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [toast, setToast] = useState(null); @@ -39,7 +40,7 @@ export function AdminOverrideCodesPage() { } if (!normalizedCode) { setToast({ - message: "Enter your current override code before activating a new one.", + message: "Enter your current 4-digit override code before activating a new one.", type: "error", }); return null; @@ -54,7 +55,7 @@ export function AdminOverrideCodesPage() { const nextCode = newOverrideCode.trim(); if (!nextCode) { - setToast({ message: "Enter the new override code before activating the change.", type: "error" }); + setToast({ message: "Enter the new 4-digit override code before activating the change.", type: "error" }); return; } const currentCode = requireCurrentCode(); @@ -75,7 +76,7 @@ export function AdminOverrideCodesPage() { } const nextCode = newOverrideCode.trim(); if (!nextCode) { - setToast({ message: "Enter the new override code before activating the change.", type: "error" }); + setToast({ message: "Enter the new 4-digit override code before activating the change.", type: "error" }); return; } @@ -97,6 +98,19 @@ export function AdminOverrideCodesPage() { } } + async function handleForgotOverrideCode() { + setToast(null); + setIsSendingReset(true); + try { + const response = await requestOverrideCodeReset(); + setToast({ message: response.message, type: "success" }); + } catch (error) { + setToast({ message: error instanceof Error ? error.message : "Could not send the override reset email.", type: "error" }); + } finally { + setIsSendingReset(false); + } + } + return ( {(currentAdmin) => ( @@ -118,7 +132,7 @@ export function AdminOverrideCodesPage() {
@@ -131,6 +145,18 @@ export function AdminOverrideCodesPage() {

{currentAdmin.has_override_code ? "Override code already configured" : "No override code configured yet"}

+ {currentAdmin.has_override_code ? ( +
+ +
+ ) : null}
@@ -139,13 +165,12 @@ export function AdminOverrideCodesPage() { {currentAdmin.has_override_code ? "Old override code" : "Initial setup"} {currentAdmin.has_override_code ? ( - setOldOverrideCode(event.target.value)} - placeholder="Enter current override code" - disabled={pending} - /> +
+ +

+ Enter your current 4-digit override code. +

+
) : (
You do not have an override code yet. Set your first code below. @@ -158,13 +183,10 @@ export function AdminOverrideCodesPage() { New override code
- setNewOverrideCode(event.target.value)} - placeholder={currentAdmin.has_override_code ? "Enter new override code" : "Set first override code"} - disabled={pending} - /> +
+ +

Only 4 digits are allowed.

+
@@ -174,6 +196,7 @@ export function AdminOverrideCodesPage() {
+ )} diff --git a/flowbit-frontend/src/components/admin/override-code-input.tsx b/flowbit-frontend/src/components/admin/override-code-input.tsx new file mode 100644 index 0000000..820464b --- /dev/null +++ b/flowbit-frontend/src/components/admin/override-code-input.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; + +type OverrideCodeInputProps = { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + autoFocus?: boolean; + className?: string; + inputClassName?: string; +}; + +const CODE_LENGTH = 4; + +function sanitizeOverrideCode(value: string) { + return value.replace(/\D/g, "").slice(0, CODE_LENGTH); +} + +export function OverrideCodeInput({ + value, + onChange, + disabled = false, + autoFocus = false, + className, + inputClassName, +}: OverrideCodeInputProps) { + const normalizedValue = useMemo(() => sanitizeOverrideCode(value), [value]); + const refs = useRef>([]); + + function focusIndex(index: number) { + const target = refs.current[index]; + if (target) { + target.focus(); + target.select(); + } + } + + function updateAt(index: number, rawValue: string) { + const sanitized = sanitizeOverrideCode(rawValue); + if (!sanitized) { + const next = normalizedValue.split(""); + next[index] = ""; + onChange(next.join("").slice(0, CODE_LENGTH)); + return; + } + + if (sanitized.length > 1) { + onChange(sanitized); + focusIndex(Math.min(sanitized.length, CODE_LENGTH) - 1); + return; + } + + const next = normalizedValue.padEnd(CODE_LENGTH, " ").split(""); + next[index] = sanitized; + onChange(next.join("").replace(/\s/g, "")); + if (index < CODE_LENGTH - 1) { + focusIndex(index + 1); + } + } + + return ( +
+ {Array.from({ length: CODE_LENGTH }, (_, index) => ( + { + refs.current[index] = element; + }} + type="text" + inputMode="numeric" + autoComplete="one-time-code" + pattern="\d{1}" + maxLength={1} + value={normalizedValue[index] ?? ""} + disabled={disabled} + autoFocus={autoFocus && index === 0} + onChange={(event) => updateAt(index, event.target.value)} + onKeyDown={(event) => { + if (event.key === "Backspace" && !normalizedValue[index] && index > 0) { + event.preventDefault(); + const next = normalizedValue.split(""); + next[index - 1] = ""; + onChange(next.join("")); + focusIndex(index - 1); + } + if (event.key === "ArrowLeft" && index > 0) { + event.preventDefault(); + focusIndex(index - 1); + } + if (event.key === "ArrowRight" && index < CODE_LENGTH - 1) { + event.preventDefault(); + focusIndex(index + 1); + } + }} + onPaste={(event) => { + event.preventDefault(); + const pasted = sanitizeOverrideCode(event.clipboardData.getData("text")); + if (!pasted) { + return; + } + onChange(pasted); + focusIndex(Math.min(pasted.length, CODE_LENGTH) - 1); + }} + className={cn( + "h-12 w-12 rounded-[18px] border border-stone-900/10 bg-stone-50 text-center text-lg font-semibold text-stone-950 outline-none transition focus:border-stone-950 disabled:cursor-not-allowed disabled:opacity-60 sm:h-14 sm:w-14 sm:text-xl", + inputClassName, + )} + /> + ))} +
+ ); +} diff --git a/flowbit-frontend/src/components/auth/login-form-card.tsx b/flowbit-frontend/src/components/auth/login-form-card.tsx index 810809e..ecb996b 100644 --- a/flowbit-frontend/src/components/auth/login-form-card.tsx +++ b/flowbit-frontend/src/components/auth/login-form-card.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowLeft, faArrowRightToBracket } from "@fortawesome/free-solid-svg-icons"; import Link from "next/link"; +import { OverrideCodeInput } from "@/components/admin/override-code-input"; import { AuthInput } from "./auth-input"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -25,6 +26,7 @@ export function LoginFormCard() { const [showSignUpSuccess, setShowSignUpSuccess] = useState(false); const [showVerifyEmailNotice, setShowVerifyEmailNotice] = useState(false); const [showDeliveryFailureNotice, setShowDeliveryFailureNotice] = useState(false); + const [useOverrideCode, setUseOverrideCode] = useState(false); const [credentials, setCredentials] = useState({ username: "", password: "" }); const [verificationEmail, setVerificationEmail] = useState(""); const [fieldErrors, setFieldErrors] = useState<{ username?: string; password?: string }>({}); @@ -72,7 +74,9 @@ export function LoginFormCard() { } if (!credentials.password) { - nextErrors.password = "Enter your password to continue."; + nextErrors.password = useOverrideCode ? "Enter your 4-digit override code to continue." : "Enter your password to continue."; + } else if (useOverrideCode && credentials.password.length !== 4) { + nextErrors.password = "Use all 4 digits of your override code."; } setFieldErrors(nextErrors); @@ -248,24 +252,38 @@ export function LoginFormCard() { } }} /> - { - setCredentials((current) => ({ ...current, password: event.target.value })); - setFieldErrors((current) => ({ ...current, password: undefined })); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - void handleLogin(); - } - }} - /> + {useOverrideCode ? ( +
+

Admin override code

+ { + setCredentials((current) => ({ ...current, password: value })); + setFieldErrors((current) => ({ ...current, password: undefined })); + }} + /> + {fieldErrors.password ?

{fieldErrors.password}

: null} +
+ ) : ( + { + setCredentials((current) => ({ ...current, password: event.target.value })); + setFieldErrors((current) => ({ ...current, password: undefined })); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleLogin(); + } + }} + /> + )}
@@ -274,9 +292,24 @@ export function LoginFormCard() { Keep me signed in on this device - - Forgot password? - +
+ {!useOverrideCode ? ( + + Forgot password? + + ) : null} + +
diff --git a/flowbit-frontend/src/components/auth/reset-override-code-form-card.tsx b/flowbit-frontend/src/components/auth/reset-override-code-form-card.tsx new file mode 100644 index 0000000..a2fe9b9 --- /dev/null +++ b/flowbit-frontend/src/components/auth/reset-override-code-form-card.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowLeft, faKey } from "@fortawesome/free-solid-svg-icons"; +import { OverrideCodeInput } from "@/components/admin/override-code-input"; +import { AuthInput } from "./auth-input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { resetOverrideCode } from "@/lib/auth-client"; + +type ResetOverrideCodeFormCardProps = { + selector: string; + token: string; +}; + +export function ResetOverrideCodeFormCard({ selector, token }: ResetOverrideCodeFormCardProps) { + const [newOverrideCode, setNewOverrideCode] = useState(""); + const [confirmOverrideCode, setConfirmOverrideCode] = useState(""); + const [accountPassword, setAccountPassword] = useState(""); + const [message, setMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [fieldErrors, setFieldErrors] = useState<{ + newOverrideCode?: string; + confirmOverrideCode?: string; + accountPassword?: string; + }>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + function validateForm() { + const nextErrors: { newOverrideCode?: string; confirmOverrideCode?: string; accountPassword?: string } = {}; + + if (newOverrideCode.length !== 4) { + nextErrors.newOverrideCode = "Enter a 4-digit override code."; + } + if (confirmOverrideCode.length !== 4) { + nextErrors.confirmOverrideCode = "Confirm the same 4-digit override code."; + } else if (confirmOverrideCode !== newOverrideCode) { + nextErrors.confirmOverrideCode = "Override codes do not match."; + } + if (!accountPassword) { + nextErrors.accountPassword = "Enter your account password."; + } + + setFieldErrors(nextErrors); + return Object.keys(nextErrors).length === 0; + } + + async function handleResetOverrideCode() { + setMessage(""); + setErrorMessage(""); + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + try { + const response = await resetOverrideCode({ + selector, + token, + new_override_code: newOverrideCode, + confirm_override_code: confirmOverrideCode, + account_password: accountPassword, + }); + setMessage(response.message); + setNewOverrideCode(""); + setConfirmOverrideCode(""); + setAccountPassword(""); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Unable to reset override code."); + } finally { + setIsSubmitting(false); + } + } + + return ( + +
+
+

Reset Override Code

+

Set a new 4-digit code

+
+ + + Back to login + +
+ +

+ Enter a new 4-digit override code, confirm it, and then verify your normal account password to finish the reset. +

+ +
+
+

New override code

+ { + setNewOverrideCode(value); + setFieldErrors((current) => ({ ...current, newOverrideCode: undefined })); + }} + autoFocus + /> + {fieldErrors.newOverrideCode ?

{fieldErrors.newOverrideCode}

: null} +
+ +
+

Confirm override code

+ { + setConfirmOverrideCode(value); + setFieldErrors((current) => ({ ...current, confirmOverrideCode: undefined })); + }} + /> + {fieldErrors.confirmOverrideCode ?

{fieldErrors.confirmOverrideCode}

: null} +
+ + { + setAccountPassword(event.target.value); + setFieldErrors((current) => ({ ...current, accountPassword: undefined })); + }} + /> +
+ + {message ? ( +
+ {message} +
+ ) : null} + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ + + Open login + +
+ + + +

Security Note

+

Keep your override code private

+

+ This code approves protected admin actions. Do not reuse old patterns, and only share it with yourself. +

+
+
+
+ ); +} diff --git a/flowbit-frontend/src/components/auth/reset-override-code-page.tsx b/flowbit-frontend/src/components/auth/reset-override-code-page.tsx new file mode 100644 index 0000000..97fe6e6 --- /dev/null +++ b/flowbit-frontend/src/components/auth/reset-override-code-page.tsx @@ -0,0 +1,15 @@ +import { AuthShell } from "./auth-shell"; +import { ResetOverrideCodeFormCard } from "./reset-override-code-form-card"; + +type ResetOverrideCodePageProps = { + selector: string; + token: string; +}; + +export function ResetOverrideCodePage({ selector, token }: ResetOverrideCodePageProps) { + return ( + + + + ); +} diff --git a/flowbit-frontend/src/components/profile/profile-danger-zone-card.tsx b/flowbit-frontend/src/components/profile/profile-danger-zone-card.tsx index d352bab..e6e3cd1 100644 --- a/flowbit-frontend/src/components/profile/profile-danger-zone-card.tsx +++ b/flowbit-frontend/src/components/profile/profile-danger-zone-card.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTriangleExclamation, faTrashCan } from "@fortawesome/free-solid-svg-icons"; -import { AuthInput } from "@/components/auth/auth-input"; +import { OverrideCodeInput } from "@/components/admin/override-code-input"; import { Button } from "@/components/ui/button"; import { ProfileDeleteModal } from "@/components/profile/profile-delete-modal"; import { clearStoredSession, deleteCurrentUserAccount, type AuthUser } from "@/lib/auth-client"; @@ -75,13 +75,10 @@ export function ProfileDangerZoneCard({ user }: ProfileDangerZoneCardProps) { isSubmitting={isDeleting} > {!user.role || user.role !== "admin" ? ( - setAdminOverrideCode(event.target.value)} - /> +
+

Admin override code

+ +
) : null} {errorMessage ? ( diff --git a/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx b/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx index f6305fc..64b08fe 100644 --- a/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx +++ b/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx @@ -1,8 +1,8 @@ "use client"; import { useState } from "react"; +import { OverrideCodeInput } from "@/components/admin/override-code-input"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import type { FlowBitTicketDetail } from "@/lib/ticket-client"; type TicketRefundModalProps = { @@ -341,12 +341,11 @@ export function TicketRefundModal({ Admin override code - onCodeChange(event.target.value)} - placeholder="Enter override code" + onChange={onCodeChange} disabled={Boolean(busyAction)} + autoFocus /> ) : null} diff --git a/flowbit-frontend/src/lib/auth-client.ts b/flowbit-frontend/src/lib/auth-client.ts index 8801c58..193596e 100644 --- a/flowbit-frontend/src/lib/auth-client.ts +++ b/flowbit-frontend/src/lib/auth-client.ts @@ -42,6 +42,14 @@ type PasswordResetPayload = { new_password: string; }; +type OverrideResetPayload = { + selector: string; + token: string; + new_override_code: string; + confirm_override_code: string; + account_password: string; +}; + type EmailVerificationPayload = { selector: string; token: string; @@ -153,6 +161,19 @@ export async function requestPasswordReset(email: string) { }); } +export async function requestOverrideCodeReset() { + const token = getStoredToken(); + if (!token) { + throw new Error("No session found."); + } + + return apiRequest<{ message: string }>("/auth/forgot-override-code/", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({}), + }); +} + export async function resetPassword(payload: PasswordResetPayload, remember = false) { const response = await apiRequest("/auth/reset-password/", { method: "POST", @@ -162,6 +183,13 @@ export async function resetPassword(payload: PasswordResetPayload, remember = fa return response; } +export async function resetOverrideCode(payload: OverrideResetPayload) { + return apiRequest<{ message: string }>("/auth/reset-override-code/", { + method: "POST", + body: JSON.stringify(payload), + }); +} + export async function changePassword(payload: { current_password: string; new_password: string }) { const token = getStoredToken(); if (!token) { diff --git a/flowbit-frontend/src/proxy.ts b/flowbit-frontend/src/proxy.ts index dc8ba3c..85bbc49 100644 --- a/flowbit-frontend/src/proxy.ts +++ b/flowbit-frontend/src/proxy.ts @@ -2,19 +2,21 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { AUTH_COOKIE_NAME } from "@/lib/auth"; -const authRoutes = new Set(["/login", "/login-help", "/sign-in", "/forgot-password", "/reset-password", "/sign-up", "/verify-email"]); +const guestOnlyRoutes = new Set(["/login", "/login-help", "/sign-in", "/forgot-password", "/sign-up"]); +const sharedAccessRoutes = new Set(["/reset-password", "/reset-override-code", "/verify-email"]); export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const hasSession = Boolean(request.cookies.get(AUTH_COOKIE_NAME)?.value); - const isAuthRoute = authRoutes.has(pathname); + const isGuestOnlyRoute = guestOnlyRoutes.has(pathname); + const isSharedAccessRoute = sharedAccessRoutes.has(pathname); - if (!hasSession && !isAuthRoute) { + if (!hasSession && !isGuestOnlyRoute && !isSharedAccessRoute) { const loginUrl = new URL("/login", request.url); return NextResponse.redirect(loginUrl); } - if (hasSession && isAuthRoute) { + if (hasSession && isGuestOnlyRoute) { const homeUrl = new URL("/", request.url); return NextResponse.redirect(homeUrl); } From 953ed7d9a7e0452872e40bdcdf91166921878bcf Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Thu, 28 May 2026 11:28:06 +0100 Subject: [PATCH 3/3] Polish override reset login flow --- .../src/components/auth/login-form-card.tsx | 77 ++++++------------- .../auth/reset-override-code-form-card.tsx | 6 +- 2 files changed, 27 insertions(+), 56 deletions(-) diff --git a/flowbit-frontend/src/components/auth/login-form-card.tsx b/flowbit-frontend/src/components/auth/login-form-card.tsx index ecb996b..810809e 100644 --- a/flowbit-frontend/src/components/auth/login-form-card.tsx +++ b/flowbit-frontend/src/components/auth/login-form-card.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowLeft, faArrowRightToBracket } from "@fortawesome/free-solid-svg-icons"; import Link from "next/link"; -import { OverrideCodeInput } from "@/components/admin/override-code-input"; import { AuthInput } from "./auth-input"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -26,7 +25,6 @@ export function LoginFormCard() { const [showSignUpSuccess, setShowSignUpSuccess] = useState(false); const [showVerifyEmailNotice, setShowVerifyEmailNotice] = useState(false); const [showDeliveryFailureNotice, setShowDeliveryFailureNotice] = useState(false); - const [useOverrideCode, setUseOverrideCode] = useState(false); const [credentials, setCredentials] = useState({ username: "", password: "" }); const [verificationEmail, setVerificationEmail] = useState(""); const [fieldErrors, setFieldErrors] = useState<{ username?: string; password?: string }>({}); @@ -74,9 +72,7 @@ export function LoginFormCard() { } if (!credentials.password) { - nextErrors.password = useOverrideCode ? "Enter your 4-digit override code to continue." : "Enter your password to continue."; - } else if (useOverrideCode && credentials.password.length !== 4) { - nextErrors.password = "Use all 4 digits of your override code."; + nextErrors.password = "Enter your password to continue."; } setFieldErrors(nextErrors); @@ -252,38 +248,24 @@ export function LoginFormCard() { } }} /> - {useOverrideCode ? ( -
-

Admin override code

- { - setCredentials((current) => ({ ...current, password: value })); - setFieldErrors((current) => ({ ...current, password: undefined })); - }} - /> - {fieldErrors.password ?

{fieldErrors.password}

: null} -
- ) : ( - { - setCredentials((current) => ({ ...current, password: event.target.value })); - setFieldErrors((current) => ({ ...current, password: undefined })); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - void handleLogin(); - } - }} - /> - )} + { + setCredentials((current) => ({ ...current, password: event.target.value })); + setFieldErrors((current) => ({ ...current, password: undefined })); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleLogin(); + } + }} + />
@@ -292,24 +274,9 @@ export function LoginFormCard() { Keep me signed in on this device -
- {!useOverrideCode ? ( - - Forgot password? - - ) : null} - -
+ + Forgot password? +
diff --git a/flowbit-frontend/src/components/auth/reset-override-code-form-card.tsx b/flowbit-frontend/src/components/auth/reset-override-code-form-card.tsx index a2fe9b9..38484b2 100644 --- a/flowbit-frontend/src/components/auth/reset-override-code-form-card.tsx +++ b/flowbit-frontend/src/components/auth/reset-override-code-form-card.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useRouter } from "next/navigation"; import Link from "next/link"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowLeft, faKey } from "@fortawesome/free-solid-svg-icons"; @@ -16,6 +17,7 @@ type ResetOverrideCodeFormCardProps = { }; export function ResetOverrideCodeFormCard({ selector, token }: ResetOverrideCodeFormCardProps) { + const router = useRouter(); const [newOverrideCode, setNewOverrideCode] = useState(""); const [confirmOverrideCode, setConfirmOverrideCode] = useState(""); const [accountPassword, setAccountPassword] = useState(""); @@ -63,10 +65,12 @@ export function ResetOverrideCodeFormCard({ selector, token }: ResetOverrideCode confirm_override_code: confirmOverrideCode, account_password: accountPassword, }); - setMessage(response.message); + setMessage("Override code reset successfully. Redirecting to login..."); setNewOverrideCode(""); setConfirmOverrideCode(""); setAccountPassword(""); + router.push("/login"); + router.refresh(); } catch (error) { setErrorMessage(error instanceof Error ? error.message : "Unable to reset override code."); } finally {