diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerPasswordResetController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerPasswordResetController.php new file mode 100644 index 0000000..1512e93 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerPasswordResetController.php @@ -0,0 +1,35 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + if ($denied = AdminSiteScope::denyUnlessPlayerAccessible($admin, $player)) { + return $denied; + } + + $updated = $passwords->reset($player, (string) $request->validated('password')); + + return ApiResponse::success([ + 'password_reset' => true, + 'player_id' => (int) $updated->id, + ], request: $request); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php index 8947295..e1422bf 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php @@ -4,11 +4,12 @@ namespace App\Http\Controllers\Api\V1\Admin\User; use App\Models\AdminRole; use App\Support\ApiResponse; -use App\Support\AdminAuthorizationRegistry; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; +use App\Support\AdminRoleUserCounts; +use App\Support\PlatformSystemRoles; use App\Support\AdminRoleApiPresenter; +use App\Support\AdminAuthorizationRegistry; /** GET /api/v1/admin/admin-user-permission-catalog */ final class AdminPermissionCatalogController extends Controller @@ -61,12 +62,26 @@ final class AdminPermissionCatalogController extends Controller ->where('scope_type', AdminRole::SCOPE_SYSTEM) ->orderBy('slug') ->get(['id', 'slug', 'name']); + $userCounts = AdminRoleUserCounts::forRoleIds($roles->pluck('id')); + $presentedRoles = $roles->map( + static fn (AdminRole $role): array => AdminRoleApiPresenter::item( + $role, + $userCounts[(int) $role->id] ?? null, + ) + )->values(); return ApiResponse::success([ 'permissions' => $permissions, 'permission_menu_groups' => $permissionMenuGroups, 'navigation' => AdminAuthorizationRegistry::navigationItems(), - 'roles' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->values()->all(), + 'roles' => $presentedRoles->all(), + 'assignable_roles' => $presentedRoles + ->reject(static fn (array $role): bool => in_array($role['slug'], [ + PlatformSystemRoles::SLUG_AGENT, + PlatformSystemRoles::SLUG_SUPER_ADMIN, + ], true)) + ->values() + ->all(), ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php index 6a7ac2c..9ba45e3 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php @@ -6,7 +6,9 @@ use App\Models\AdminRole; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminRoleUserCounts; use App\Support\AdminRoleApiPresenter; + final class AdminRoleIndexController extends Controller { public function __invoke(): JsonResponse @@ -16,9 +18,15 @@ final class AdminRoleIndexController extends Controller ->orderBy('sort_order') ->orderBy('id') ->get(); + $userCounts = AdminRoleUserCounts::forRoleIds($roles->pluck('id')); return ApiResponse::success([ - 'items' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->values()->all(), + 'items' => $roles->map( + static fn (AdminRole $role): array => AdminRoleApiPresenter::item( + $role, + $userCounts[(int) $role->id] ?? null, + ) + )->values()->all(), ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php index 9fbc7e2..102b37e 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php @@ -3,10 +3,10 @@ namespace App\Http\Controllers\Api\V1\Admin\User; use App\Models\AdminUser; -use Illuminate\Support\Facades\DB; use Illuminate\Http\Request; use App\Support\AdminApiList; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; use App\Support\AdminUserApiPresenter; @@ -17,6 +17,7 @@ final class AdminUserIndexController extends Controller { $p = AdminApiList::readPaging($request); $keyword = trim((string) $request->query('keyword', '')); + $roleSlug = trim((string) $request->query('role_slug', '')); $q = AdminUser::query() ->with(['roles']) @@ -35,6 +36,12 @@ final class AdminUserIndexController extends Controller }); } + if ($roleSlug !== '') { + $q->whereHas('roles', static function ($roles) use ($roleSlug): void { + $roles->where('admin_roles.slug', $roleSlug); + }); + } + $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); return AdminApiList::json($paginator, fn (AdminUser $user): array => AdminUserApiPresenter::listItem($user)); diff --git a/app/Http/Controllers/Api/V1/Player/PlayerPasswordUpdateController.php b/app/Http/Controllers/Api/V1/Player/PlayerPasswordUpdateController.php new file mode 100644 index 0000000..3a8fbcd --- /dev/null +++ b/app/Http/Controllers/Api/V1/Player/PlayerPasswordUpdateController.php @@ -0,0 +1,41 @@ +lotteryPlayer(); + abort_if($player === null, 500, 'lottery_player missing'); + + $updated = $passwords->change( + $player, + (string) $request->validated('current_password'), + (string) $request->validated('password'), + ); + + AuditLogger::recordForPlayer( + $updated, + $request, + 'player_account', + 'change_password', + 'player', + (string) $updated->id, + null, + ['native_token_version' => (int) $updated->native_token_version], + ); + + return ApiResponse::success(['password_changed' => true], request: $request); + } +} diff --git a/app/Http/Requests/Admin/AdminPlayerPasswordResetRequest.php b/app/Http/Requests/Admin/AdminPlayerPasswordResetRequest.php new file mode 100644 index 0000000..8047423 --- /dev/null +++ b/app/Http/Requests/Admin/AdminPlayerPasswordResetRequest.php @@ -0,0 +1,23 @@ + [...$this->nativePlayerPasswordRules(required: true), 'confirmed'], + ]; + } +} diff --git a/app/Http/Requests/Player/PlayerPasswordUpdateRequest.php b/app/Http/Requests/Player/PlayerPasswordUpdateRequest.php new file mode 100644 index 0000000..c42dfde --- /dev/null +++ b/app/Http/Requests/Player/PlayerPasswordUpdateRequest.php @@ -0,0 +1,24 @@ + ['required', 'string', 'max:128'], + 'password' => [...$this->nativePlayerPasswordRules(required: true), 'confirmed'], + ]; + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 87102a6..37503e3 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -251,6 +251,11 @@ final class AdminUser extends Authenticatable { $slugs = array_values(array_unique($slugs)); \App\Support\SuperAdminAccount::assertNotSiteRoleAssignment($slugs); + if (in_array(\App\Support\PlatformSystemRoles::SLUG_AGENT, $slugs, true)) { + throw ValidationException::withMessages([ + 'role_slugs' => [trans('admin.agent_role_not_assignable_to_platform_account')], + ]); + } $roleIds = DB::table('admin_roles') ->where('scope_type', AdminRole::SCOPE_SYSTEM) diff --git a/app/Models/Player.php b/app/Models/Player.php index 3bc97dc..1de41c3 100644 --- a/app/Models/Player.php +++ b/app/Models/Player.php @@ -4,8 +4,8 @@ namespace App\Models; use App\Support\PlayerAuthSource; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * 主站玩家在本地映射账号(表 players),与 SSO JWT 中 site_code + site_player_id 对应。 @@ -27,6 +27,7 @@ final class Player extends Model 'last_login_at', 'login_failed_count', 'login_locked_until', + 'native_token_version', ]; protected $hidden = [ @@ -40,6 +41,7 @@ final class Player extends Model 'last_login_at' => 'datetime', 'login_failed_count' => 'integer', 'login_locked_until' => 'datetime', + 'native_token_version' => 'integer', 'risk_tags' => 'array', ]; } diff --git a/app/Services/Player/PlayerNativeAuthService.php b/app/Services/Player/PlayerNativeAuthService.php index 1aaaed5..9faea0f 100644 --- a/app/Services/Player/PlayerNativeAuthService.php +++ b/app/Services/Player/PlayerNativeAuthService.php @@ -2,11 +2,10 @@ namespace App\Services\Player; -use App\Lottery\ErrorCode; -use App\Models\Player; -use App\Support\PlayerAuthSource; use Firebase\JWT\JWT; -use Illuminate\Support\Facades\DB; +use App\Models\Player; +use App\Lottery\ErrorCode; +use App\Support\PlayerAuthSource; use Illuminate\Support\Facades\Hash; use App\Exceptions\PlayerAuthenticationException; @@ -103,6 +102,7 @@ final class PlayerNativeAuthService $payload = [ $playerIdKey => (int) $player->id, $authKey => PlayerAuthSource::LOTTERY_NATIVE, + 'token_version' => (int) ($player->native_token_version ?? 0), 'site_code' => (string) $player->site_code, 'iat' => $now, 'exp' => $now + $ttl, diff --git a/app/Services/Player/PlayerPasswordService.php b/app/Services/Player/PlayerPasswordService.php new file mode 100644 index 0000000..6beb188 --- /dev/null +++ b/app/Services/Player/PlayerPasswordService.php @@ -0,0 +1,64 @@ +lockForUpdate()->findOrFail($player->id); + $this->assertPasswordManagedLocally($locked); + + if (! is_string($locked->password_hash) || ! Hash::check($currentPassword, $locked->password_hash)) { + throw ValidationException::withMessages([ + 'current_password' => ['current_password_invalid'], + ]); + } + + if (Hash::check($newPassword, $locked->password_hash)) { + throw ValidationException::withMessages([ + 'password' => ['new_password_must_differ'], + ]); + } + + return $this->persist($locked, $newPassword); + }); + } + + public function reset(Player $player, string $newPassword): Player + { + return DB::transaction(function () use ($player, $newPassword): Player { + $locked = Player::query()->lockForUpdate()->findOrFail($player->id); + $this->assertPasswordManagedLocally($locked); + + return $this->persist($locked, $newPassword); + }); + } + + private function assertPasswordManagedLocally(Player $player): void + { + if (! $player->isLotteryNative()) { + throw ValidationException::withMessages([ + 'password' => ['native_password_unavailable'], + ]); + } + } + + private function persist(Player $player, string $newPassword): Player + { + $player->forceFill([ + 'password_hash' => Hash::make($newPassword), + 'native_token_version' => (int) $player->native_token_version + 1, + 'login_failed_count' => 0, + 'login_locked_until' => null, + ])->save(); + + return $player->refresh(); + } +} diff --git a/app/Services/PlayerTokenResolver.php b/app/Services/PlayerTokenResolver.php index 431b0c8..86f13a2 100644 --- a/app/Services/PlayerTokenResolver.php +++ b/app/Services/PlayerTokenResolver.php @@ -8,10 +8,10 @@ use App\Models\Player; use App\Lottery\ErrorCode; use Illuminate\Http\Request; use App\Support\PlayerAuthSource; -use App\Support\PlayerAutoRegistrationDefaults; use App\Support\PlayerFundingMode; use App\Support\PlayerTokenAesUnwrap; use Illuminate\Database\QueryException; +use App\Support\PlayerAutoRegistrationDefaults; use App\Exceptions\PlayerAuthenticationException; use App\Services\Integration\PartnerSiteConfigResolver; @@ -192,6 +192,11 @@ final class PlayerTokenResolver throw new PlayerAuthenticationException('玩家不存在', ErrorCode::PlayerNotRegistered->value); } + $tokenVersion = (int) data_get($claims, 'token_version', 0); + if ($tokenVersion !== (int) ($player->native_token_version ?? 0)) { + throw new PlayerAuthenticationException('Token 已因密码变更失效', ErrorCode::PlayerTokenInvalid->value); + } + $player->forceFill(['last_login_at' => now()])->save(); return $player->refresh(); @@ -288,9 +293,6 @@ final class PlayerTokenResolver return is_string($decoded) ? $decoded : ''; } - /** - * @param object $claims - */ private function assertNativeJwtTemporalPolicy(object $claims): void { if (! isset($claims->exp) || ! is_numeric($claims->exp)) { diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index f363281..bb44fb8 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -550,6 +550,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.players.store', 'module_code' => 'player_service', 'name' => '创建玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']], ['code' => 'admin.players.show', 'module_code' => 'player_service', 'name' => '玩家详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']], ['code' => 'admin.players.update', 'module_code' => 'player_service', 'name' => '更新玩家', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']], + ['code' => 'admin.players.password.reset', 'module_code' => 'player_service', 'name' => '重置玩家密码', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}/password', 'route_name' => 'api.v1.admin.players.password.reset', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']], ['code' => 'admin.players.destroy', 'module_code' => 'player_service', 'name' => '删除玩家', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']], ['code' => 'admin.players.freeze', 'module_code' => 'player_service', 'name' => '冻结玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/freeze', 'route_name' => 'api.v1.admin.players.freeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']], ['code' => 'admin.players.unfreeze', 'module_code' => 'player_service', 'name' => '解冻玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/unfreeze', 'route_name' => 'api.v1.admin.players.unfreeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']], diff --git a/app/Support/AdminRoleApiPresenter.php b/app/Support/AdminRoleApiPresenter.php index 5387edb..dfb2f03 100644 --- a/app/Support/AdminRoleApiPresenter.php +++ b/app/Support/AdminRoleApiPresenter.php @@ -7,8 +7,25 @@ use App\Models\AdminRole; final class AdminRoleApiPresenter { /** @return array */ - public static function item(AdminRole $role): array + public static function item(AdminRole $role, ?array $userCounts = null): array { + if ($userCounts !== null) { + $counts = $userCounts; + } elseif (($role->scope_type ?? AdminRole::SCOPE_SYSTEM) === AdminRole::SCOPE_SYSTEM) { + $counts = AdminRoleUserCounts::forRoleIds([$role->id])[(int) $role->id] ?? [ + 'user_count' => 0, + 'platform_user_count' => 0, + 'agent_user_count' => 0, + ]; + } else { + $assignedCount = $role->assignedUserCount(); + $counts = [ + 'user_count' => $assignedCount, + 'platform_user_count' => 0, + 'agent_user_count' => $assignedCount, + ]; + } + return [ 'id' => (int) $role->id, 'slug' => $role->slug, @@ -22,7 +39,9 @@ final class AdminRoleApiPresenter 'delegated_from_role_id' => $role->delegated_from_role_id !== null ? (int) $role->delegated_from_role_id : null, 'is_read_only_template' => $role->isReadOnlyTemplate(), 'permission_slugs' => $role->legacyPermissionSlugs(), - 'user_count' => $role->assignedUserCount(), + 'user_count' => $counts['user_count'], + 'platform_user_count' => $counts['platform_user_count'], + 'agent_user_count' => $counts['agent_user_count'], ]; } } diff --git a/app/Support/AdminRoleUserCounts.php b/app/Support/AdminRoleUserCounts.php new file mode 100644 index 0000000..6132d63 --- /dev/null +++ b/app/Support/AdminRoleUserCounts.php @@ -0,0 +1,46 @@ + $roleIds + * @return array + */ + public static function forRoleIds(iterable $roleIds): array + { + $ids = collect($roleIds) + ->map(static fn ($id): int => (int) $id) + ->unique() + ->values(); + + if ($ids->isEmpty()) { + return []; + } + + return DB::table('admin_user_site_roles as usr') + ->leftJoin('admin_user_agents as uag', 'uag.admin_user_id', '=', 'usr.admin_user_id') + ->whereIn('usr.role_id', $ids->all()) + ->groupBy('usr.role_id') + ->selectRaw( + 'usr.role_id, ' + .'COUNT(DISTINCT CASE WHEN uag.admin_user_id IS NULL THEN usr.admin_user_id END) AS platform_user_count, ' + .'COUNT(DISTINCT CASE WHEN uag.admin_user_id IS NOT NULL THEN usr.admin_user_id END) AS agent_user_count' + ) + ->get() + ->mapWithKeys(static function (object $row): array { + $platformCount = (int) $row->platform_user_count; + $agentCount = (int) $row->agent_user_count; + + return [(int) $row->role_id => [ + 'user_count' => $platformCount + $agentCount, + 'platform_user_count' => $platformCount, + 'agent_user_count' => $agentCount, + ]]; + }) + ->all(); + } +} diff --git a/app/Support/InvalidPlatformAgentRoleCleanup.php b/app/Support/InvalidPlatformAgentRoleCleanup.php new file mode 100644 index 0000000..1e7e560 --- /dev/null +++ b/app/Support/InvalidPlatformAgentRoleCleanup.php @@ -0,0 +1,30 @@ +where('scope_type', AdminRole::SCOPE_SYSTEM) + ->where('slug', PlatformSystemRoles::SLUG_AGENT) + ->value('id'); + + if ($agentRoleId === null) { + return 0; + } + + return DB::table('admin_user_site_roles') + ->where('role_id', (int) $agentRoleId) + ->whereNotExists(static function ($query): void { + $query->selectRaw('1') + ->from('admin_user_agents as cleanup_uag') + ->whereColumn('cleanup_uag.admin_user_id', 'admin_user_site_roles.admin_user_id'); + }) + ->delete(); + } +} diff --git a/database/migrations/2026_07_21_120000_add_native_token_version_to_players.php b/database/migrations/2026_07_21_120000_add_native_token_version_to_players.php new file mode 100644 index 0000000..2ee7149 --- /dev/null +++ b/database/migrations/2026_07_21_120000_add_native_token_version_to_players.php @@ -0,0 +1,24 @@ +unsignedInteger('native_token_version') + ->default(0) + ->after('login_locked_until'); + }); + } + + public function down(): void + { + Schema::table('players', function (Blueprint $table): void { + $table->dropColumn('native_token_version'); + }); + } +}; diff --git a/database/migrations/2026_07_21_130000_remove_agent_role_from_unbound_platform_accounts.php b/database/migrations/2026_07_21_130000_remove_agent_role_from_unbound_platform_accounts.php new file mode 100644 index 0000000..04c1417 --- /dev/null +++ b/database/migrations/2026_07_21_130000_remove_agent_role_from_unbound_platform_accounts.php @@ -0,0 +1,17 @@ + 'Agent accounts must be managed in Agent Operations, not in the platform accounts page.', 'agent_role_managed_in_agents' => 'Agent roles must be managed in Agent Operations, not in the platform roles page.', 'system_roles_only' => 'Platform accounts can only be assigned platform roles.', + 'agent_role_not_assignable_to_platform_account' => 'The Agent role cannot be assigned to a platform account. Create or bind agent accounts in Agent Management.', 'user_cannot_delete_self' => 'Cannot delete your own account.', 'user_cannot_delete_last_super_admin' => 'Cannot delete the last super admin.', 'super_admin_only_for_roles' => 'Only super admins can manage roles.', diff --git a/lang/en/validation_attributes.php b/lang/en/validation_attributes.php index fd4fc6d..bdbf445 100644 --- a/lang/en/validation_attributes.php +++ b/lang/en/validation_attributes.php @@ -168,4 +168,6 @@ return [ 'supports_multi_number' => 'supports multi-number', 'reserved_rule_json' => 'reserved rules', 'extra_config_json' => 'extra config', + 'current_password' => 'current password', + 'password_confirmation' => 'password confirmation', ]; diff --git a/lang/en/validation_business.php b/lang/en/validation_business.php index 0e21ede..aa56609 100644 --- a/lang/en/validation_business.php +++ b/lang/en/validation_business.php @@ -23,6 +23,9 @@ return [ 'agent_profile_required' => 'Configure share rate and credit on the agent profile first.', 'parent_profile_required' => 'Configure share rate and credit on the parent agent profile first.', 'wallet_player_prohibited' => 'Wallet players cannot have credit limits or rebate settings.', + 'current_password_invalid' => 'The current password is incorrect.', + 'new_password_must_differ' => 'The new password must differ from the current password.', + 'native_password_unavailable' => 'Main-site SSO players do not use a lottery password.', 'exceeds_unpaid' => 'Payment amount cannot exceed the unpaid balance on this bill.', 'invalid_range' => 'Share rate must be between 0 and 100.', 'below_allocated' => 'Credit limit cannot be lower than credit already allocated to sub-agents.', diff --git a/lang/ne/admin.php b/lang/ne/admin.php index a1ff7f7..6e5a8ed 100644 --- a/lang/ne/admin.php +++ b/lang/ne/admin.php @@ -41,6 +41,7 @@ return [ 'agent_account_managed_in_agents' => 'एजेन्ट खाता प्लेटफर्म खाता पृष्ठबाट होइन, एजेन्ट अपरेसनबाट व्यवस्थापन गर्नुपर्छ।', 'agent_role_managed_in_agents' => 'एजेन्ट भूमिका प्लेटफर्म भूमिका पृष्ठबाट होइन, एजेन्ट अपरेसनबाट व्यवस्थापन गर्नुपर्छ।', 'system_roles_only' => 'प्लेटफर्म खातामा प्लेटफर्म भूमिका मात्र बाँड्न सकिन्छ।', + 'agent_role_not_assignable_to_platform_account' => 'प्लेटफर्म खातामा “एजेन्ट” भूमिका दिन मिल्दैन। एजेन्ट व्यवस्थापनमा एजेन्ट खाता सिर्जना वा बाँध्नुहोस्।', 'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।', 'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।', 'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।', diff --git a/lang/ne/validation_attributes.php b/lang/ne/validation_attributes.php index fa09b9a..ad9131a 100644 --- a/lang/ne/validation_attributes.php +++ b/lang/ne/validation_attributes.php @@ -168,4 +168,6 @@ return [ 'supports_multi_number' => 'बहु-नम्बर समर्थन', 'reserved_rule_json' => 'आरक्षित नियमहरू', 'extra_config_json' => 'अतिरिक्त कन्फिग', + 'current_password' => 'हालको पासवर्ड', + 'password_confirmation' => 'पासवर्ड पुष्टि', ]; diff --git a/lang/ne/validation_business.php b/lang/ne/validation_business.php index c336030..62c81d0 100644 --- a/lang/ne/validation_business.php +++ b/lang/ne/validation_business.php @@ -23,6 +23,9 @@ return [ 'agent_profile_required' => 'पहिले एजेन्ट प्रोफाइलमा शेयर दर र क्रेडिट कन्फिगर गर्नुहोस्।', 'parent_profile_required' => 'पहिले माथिल्लो एजेन्ट प्रोफाइलमा शेयर दर र क्रेडिट कन्फिगर गर्नुहोस्।', 'wallet_player_prohibited' => 'वालेट खेलाडीहरूसँग क्रेडिट सीमा वा रिबेट सेटिङ हुन सक्दैन।', + 'current_password_invalid' => 'हालको पासवर्ड गलत छ।', + 'new_password_must_differ' => 'नयाँ पासवर्ड हालको पासवर्डभन्दा फरक हुनुपर्छ।', + 'native_password_unavailable' => 'मुख्य साइट SSO खेलाडीले लटरी पासवर्ड प्रयोग गर्दैन।', 'exceeds_unpaid' => 'भुक्तानी रकम यस बिलको बाँकी रकम भन्दा बढी हुन सक्दैन।', 'invalid_range' => 'शेयर दर ० र १०० बीचमा हुनुपर्छ।', 'below_allocated' => 'क्रेडिट सीमा तलका एजेन्टहरूलाई पहिले नै आवण्टित क्रेडिट भन्दा कम हुन सक्दैन।', diff --git a/lang/zh/admin.php b/lang/zh/admin.php index 4bcaced..c6cdaf6 100644 --- a/lang/zh/admin.php +++ b/lang/zh/admin.php @@ -45,6 +45,7 @@ return [ 'agent_account_managed_in_agents' => '代理账号请到「代理经营」中管理,平台账号页不再支持此操作。', 'agent_role_managed_in_agents' => '代理角色请到「代理经营」中管理,平台角色页不再支持此操作。', 'system_roles_only' => '平台账号只能分配平台角色。', + 'agent_role_not_assignable_to_platform_account' => '平台账号不能分配“代理”角色;请在“代理管理”中创建或绑定代理账号。', 'user_cannot_delete_self' => '不能删除当前登录账号。', 'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。', 'super_admin_only_for_roles' => '仅超级管理员可管理角色。', diff --git a/lang/zh/validation_attributes.php b/lang/zh/validation_attributes.php index c500fde..5adc3e9 100644 --- a/lang/zh/validation_attributes.php +++ b/lang/zh/validation_attributes.php @@ -169,4 +169,6 @@ return [ 'can_grant_extra_rebate' => '允许额外回水', 'can_create_child_agent' => '允许创建下级代理', 'can_create_player' => '允许创建玩家', + 'current_password' => '当前密码', + 'password_confirmation' => '确认密码', ]; diff --git a/lang/zh/validation_business.php b/lang/zh/validation_business.php index 57bb5c0..7458d0c 100644 --- a/lang/zh/validation_business.php +++ b/lang/zh/validation_business.php @@ -24,6 +24,9 @@ return [ 'exceeds_default_rebate_limit' => '默认玩家回水不能超过本节点回水上限。', 'not_allowed' => '当前代理未开放该能力,无法设置。', 'wallet_player_prohibited' => '主站钱包玩家不支持授信额度与回水设置。', + 'current_password_invalid' => '当前密码不正确。', + 'new_password_must_differ' => '新密码不能与当前密码相同。', + 'native_password_unavailable' => '主站 SSO 玩家不使用彩票端密码。', 'exceeds_unpaid' => '收付金额不能超过账单未结金额。', 'invalid_range' => '占成比例必须在 0–100 之间。', 'below_allocated' => '代理授信额度不能低于已下发给下级代理与玩家的总额。', diff --git a/routes/api/v1/admin/player.php b/routes/api/v1/admin/player.php index 2e45115..afc008c 100644 --- a/routes/api/v1/admin/player.php +++ b/routes/api/v1/admin/player.php @@ -1,14 +1,15 @@ name('api.v1.admin.players.show'); Route::put('players/{player}', AdminPlayerUpdateController::class) ->name('api.v1.admin.players.update'); + Route::put('players/{player}/password', AdminPlayerPasswordResetController::class) + ->name('api.v1.admin.players.password.reset'); Route::post('players/{player}/freeze', AdminPlayerFreezeController::class) ->name('api.v1.admin.players.freeze'); Route::post('players/{player}/unfreeze', AdminPlayerUnfreezeController::class) diff --git a/routes/api/v1/player.php b/routes/api/v1/player.php index bda7c0d..66b6d17 100644 --- a/routes/api/v1/player.php +++ b/routes/api/v1/player.php @@ -6,13 +6,14 @@ use App\Http\Controllers\Api\V1\Wallet\WalletLogsController; use App\Http\Controllers\Api\V1\Ticket\TicketPlaceController; use App\Http\Controllers\Api\V1\Ticket\TicketPreviewController; use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController; -use App\Http\Controllers\Api\V1\Wallet\WalletSettlementBillsController; use App\Http\Controllers\Api\V1\Ticket\TicketItemShowController; use App\Http\Controllers\Api\V1\Ticket\TicketItemsIndexController; use App\Http\Controllers\Api\V1\Wallet\WalletTransferInController; use App\Http\Controllers\Api\V1\Ticket\TicketDrawMyMatchController; use App\Http\Controllers\Api\V1\Wallet\WalletTransferOutController; +use App\Http\Controllers\Api\V1\Player\PlayerPasswordUpdateController; use App\Http\Controllers\Api\V1\BetProvider\BetProviderIndexController; +use App\Http\Controllers\Api\V1\Wallet\WalletSettlementBillsController; /** * 玩家端路由(需 middleware lottery.player)。 @@ -23,6 +24,7 @@ Route::middleware('lottery.player')->group(function (): void { ->name('api.v1.player.') ->group(function (): void { Route::get('me', MeController::class)->name('me'); + Route::put('password', PlayerPasswordUpdateController::class)->name('password.update'); }); // 钱包 diff --git a/tests/Feature/AdminPlatformRoleBoundaryTest.php b/tests/Feature/AdminPlatformRoleBoundaryTest.php new file mode 100644 index 0000000..468569c --- /dev/null +++ b/tests/Feature/AdminPlatformRoleBoundaryTest.php @@ -0,0 +1,214 @@ +create([ + 'username' => $username, + 'name' => 'Role Boundary Manager', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + $role = AdminRole::query()->create([ + 'slug' => 'boundary_'.$username, + 'name' => 'Boundary Manager', + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ]); + $role->syncLegacyPermissionSlugs(['prd.admin_user.manage', 'prd.admin_role.manage']); + $admin->roles()->sync([ + (int) $role->id => [ + 'site_id' => AdminUser::requireDefaultAdminSiteId(), + 'granted_at' => now(), + ], + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +function createPlatformRoleBoundaryAgent(string $username): AdminUser +{ + $siteId = AdminUser::requireDefaultAdminSiteId(); + $root = AgentNode::query() + ->where('admin_site_id', $siteId) + ->where('depth', 0) + ->firstOrFail(); + $super = AdminUser::query()->create([ + 'username' => 'super_'.$username, + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $node = app(AgentNodeService::class)->createChild($super, [ + 'parent_id' => (int) $root->id, + 'code' => 'node-'.$username, + 'name' => 'Agent '.$username, + 'username' => $username, + 'password' => 'secret-strong', + ]); + + $adminUserId = DB::table('admin_user_agents') + ->where('agent_node_id', $node->id) + ->where('is_primary', true) + ->value('admin_user_id'); + + return AdminUser::query()->findOrFail((int) $adminUserId); +} + +test('permission catalog keeps all roles but only exposes platform assignable roles', function (): void { + $token = makePlatformRoleBoundaryToken('catalog_boundary'); + + $data = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-user-permission-catalog') + ->assertOk() + ->json('data'); + + expect(collect($data['roles'])->pluck('slug')->all()) + ->toContain(PlatformSystemRoles::SLUG_AGENT, PlatformSystemRoles::SLUG_SUPER_ADMIN); + expect(collect($data['assignable_roles'])->pluck('slug')->all()) + ->not->toContain(PlatformSystemRoles::SLUG_AGENT, PlatformSystemRoles::SLUG_SUPER_ADMIN); +}); + +test('platform account create and role update reject the agent role', function (): void { + $token = makePlatformRoleBoundaryToken('assignment_boundary'); + $siteId = AdminUser::requireDefaultAdminSiteId(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->withHeader('X-Locale', 'zh') + ->postJson('/api/v1/admin/admin-users', [ + 'username' => 'illegal_agent_platform', + 'nickname' => 'Illegal Agent Platform', + 'email' => null, + 'password' => 'secret-strong', + 'status' => 0, + 'admin_site_id' => $siteId, + 'role_slugs' => [PlatformSystemRoles::SLUG_AGENT], + ]) + ->assertStatus(422) + ->assertJsonPath('code', ErrorCode::ValidationFailed->value) + ->assertJsonPath('msg', '平台账号不能分配“代理”角色;请在“代理管理”中创建或绑定代理账号。'); + + expect(AdminUser::query()->where('username', 'illegal_agent_platform')->exists())->toBeFalse(); + + $target = AdminUser::query()->create([ + 'username' => 'platform_role_target', + 'name' => 'Platform Role Target', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [ + 'admin_site_id' => $siteId, + 'role_slugs' => [PlatformSystemRoles::SLUG_AGENT], + ]) + ->assertStatus(422) + ->assertJsonPath('code', ErrorCode::ValidationFailed->value); +}); + +test('agent creation keeps its agent role and cleanup removes only unbound platform assignments', function (): void { + $agentUser = createPlatformRoleBoundaryAgent('legal_agent_boundary'); + $agentRole = AdminRole::query() + ->where('scope_type', AdminRole::SCOPE_SYSTEM) + ->where('slug', PlatformSystemRoles::SLUG_AGENT) + ->firstOrFail(); + $siteId = AdminUser::requireDefaultAdminSiteId(); + + expect(DB::table('admin_user_agents')->where('admin_user_id', $agentUser->id)->exists())->toBeTrue(); + expect(DB::table('admin_user_site_roles') + ->where('admin_user_id', $agentUser->id) + ->where('role_id', $agentRole->id) + ->exists())->toBeTrue(); + + $illegalUser = AdminUser::query()->create([ + 'username' => 'unbound_agent_role', + 'name' => 'Unbound Agent Role', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $illegalUser->id, + 'site_id' => $siteId, + 'role_id' => $agentRole->id, + 'granted_at' => now(), + ]); + + expect(InvalidPlatformAgentRoleCleanup::run())->toBe(1); + expect(DB::table('admin_user_site_roles') + ->where('admin_user_id', $illegalUser->id) + ->where('role_id', $agentRole->id) + ->exists())->toBeFalse(); + expect(DB::table('admin_user_site_roles') + ->where('admin_user_id', $agentUser->id) + ->where('role_id', $agentRole->id) + ->exists())->toBeTrue(); + expect(AdminUser::query()->whereKey($illegalUser->id)->exists())->toBeTrue(); +}); + +test('role counts classify platform and agent accounts and role filter never leaks agents', function (): void { + $token = makePlatformRoleBoundaryToken('count_boundary'); + $siteId = AdminUser::requireDefaultAdminSiteId(); + $role = AdminRole::query()->create([ + 'slug' => 'counted_role', + 'name' => 'Counted Role', + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ]); + $platformUser = AdminUser::query()->create([ + 'username' => 'counted_platform', + 'name' => 'Counted Platform', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + $platformUser->roles()->sync([ + (int) $role->id => [ + 'site_id' => $siteId, + 'granted_at' => now(), + ], + ]); + $agentUser = createPlatformRoleBoundaryAgent('counted_agent'); + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $agentUser->id, + 'site_id' => $siteId, + 'role_id' => $role->id, + 'granted_at' => now(), + ]); + + $roleRow = collect($this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-roles') + ->assertOk() + ->json('data.items')) + ->firstWhere('slug', 'counted_role'); + + expect($roleRow) + ->not->toBeNull() + ->and($roleRow['platform_user_count'])->toBe(1) + ->and($roleRow['agent_user_count'])->toBe(1) + ->and($roleRow['user_count'])->toBe(2); + + $items = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-users?role_slug=counted_role') + ->assertOk() + ->json('data.items'); + + expect(collect($items)->pluck('username')->all()) + ->toBe(['counted_platform']) + ->not->toContain('counted_agent'); +}); diff --git a/tests/Feature/AdminPlayerManageApiTest.php b/tests/Feature/AdminPlayerManageApiTest.php index 2c6dabd..88f6f6c 100644 --- a/tests/Feature/AdminPlayerManageApiTest.php +++ b/tests/Feature/AdminPlayerManageApiTest.php @@ -1,21 +1,28 @@ 'test-native-jwt-secret-32bytes!!', + 'lottery.player_auth.native.ttl_seconds' => 3600, + ]); $this->seed(CurrencySeeder::class); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); }); @@ -251,7 +258,7 @@ test('agent players list can filter direct players without including downline pl ]); grantSuperAdminRole($super); - $child = app(\App\Services\Agent\AgentNodeService::class)->createChild($super, [ + $child = app(AgentNodeService::class)->createChild($super, [ 'parent_id' => $rootId, 'code' => 'direct-child', 'name' => 'Direct Child', @@ -355,7 +362,7 @@ test('admin cannot change credit player default currency', function (): void { 'default_currency' => 'USD', ]) ->assertStatus(422) - ->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); + ->assertJsonPath('code', ErrorCode::ValidationFailed->value); $this->assertDatabaseHas('players', [ 'id' => $player->id, @@ -444,14 +451,14 @@ test('wallet player update rejects credit limit and rebate', function (): void { 'credit_limit' => 1000, ]) ->assertStatus(422) - ->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); + ->assertJsonPath('code', ErrorCode::ValidationFailed->value); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/players/'.$player->id, [ 'rebate_rate' => 1, ]) ->assertStatus(422) - ->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); + ->assertJsonPath('code', ErrorCode::ValidationFailed->value); }); test('native player create rejects chinese username', function (): void { @@ -467,7 +474,7 @@ test('native player create rejects chinese username', function (): void { 'default_currency' => 'NPR', ]) ->assertStatus(422) - ->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); + ->assertJsonPath('code', ErrorCode::ValidationFailed->value); }); test('partial rebate update preserves the other rebate field', function (): void { @@ -555,3 +562,99 @@ test('partial rebate update preserves the other rebate field', function (): void 'extra_rebate_rate' => 0.001, ]); }); + +test('admin can reset native player password and invalidate active tokens', function (): void { + $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); + $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native-admin-reset', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'native_admin_reset', + 'password_hash' => Hash::make('old-secret'), + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $oldToken = app(PlayerNativeAuthService::class)->issueToken($player); + $adminToken = playerManageAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$adminToken) + ->putJson('/api/v1/admin/players/'.$player->id.'/password', [ + 'password' => 'reset-secret', + 'password_confirmation' => 'reset-secret', + ]) + ->assertOk() + ->assertJsonPath('data.password_reset', true) + ->assertJsonPath('data.player_id', $player->id); + + $player->refresh(); + expect(Hash::check('reset-secret', (string) $player->password_hash))->toBeTrue() + ->and($player->native_token_version)->toBe(1); + + $this->withHeader('Authorization', 'Bearer '.$oldToken) + ->getJson('/api/v1/player/me') + ->assertStatus(401) + ->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value); + + $this->assertDatabaseHas('audit_logs', [ + 'operator_type' => 'admin', + 'module_code' => 'player_service', + 'action_code' => 'reset', + 'target_id' => (string) $player->id, + ]); +}); + +test('admin cannot reset password for sso player', function (): void { + $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); + $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'sso-admin-reset-blocked', + 'auth_source' => PlayerAuthSource::MAIN_SITE_SSO, + 'funding_mode' => PlayerFundingMode::WALLET, + 'username' => 'sso_admin_reset_blocked', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer '.playerManageAdminToken()) + ->putJson('/api/v1/admin/players/'.$player->id.'/password', [ + 'password' => 'reset-secret', + 'password_confirmation' => 'reset-secret', + ]) + ->assertStatus(422) + ->assertJsonPath('msg', 'Main-site SSO players do not use a lottery password.'); +}); + +test('player view permission cannot reset native player password', function (): void { + $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); + $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native-view-reset-blocked', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'native_view_reset_blocked', + 'password_hash' => Hash::make('old-secret'), + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $viewToken = playerPermissionAdminToken('player_password_view_only', ['prd.users.view']); + + playerPermissionRequest($this, $viewToken) + ->putJson('/api/v1/admin/players/'.$player->id.'/password', [ + 'password' => 'reset-secret', + 'password_confirmation' => 'reset-secret', + ]) + ->assertStatus(403); + + expect(Hash::check('old-secret', (string) $player->fresh()->password_hash))->toBeTrue(); +}); diff --git a/tests/Feature/PlayerNativeAuthTest.php b/tests/Feature/PlayerNativeAuthTest.php index 845b443..85b6877 100644 --- a/tests/Feature/PlayerNativeAuthTest.php +++ b/tests/Feature/PlayerNativeAuthTest.php @@ -1,15 +1,18 @@ 0, ]); - $auth = app(\App\Services\Player\PlayerNativeAuthService::class); + $auth = app(PlayerNativeAuthService::class); $token = $auth->issueToken($player); $response = $this->withHeader('Authorization', 'Bearer '.$token) @@ -293,7 +296,7 @@ test('sso wallet player balance does not use credit when site credit mode on', f 'status' => 0, ]); - \App\Models\PlayerWallet::query()->create([ + PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', @@ -311,3 +314,108 @@ test('sso wallet player balance does not use credit when site credit mode on', f ->assertJsonPath('data.funding_mode', PlayerFundingMode::WALLET) ->assertJsonPath('data.available_balance', 12000); }); + +test('native player can change password and previous tokens are invalidated', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native:password-change', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'password_change_user', + 'password_hash' => Hash::make('old-secret'), + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $oldToken = app(PlayerNativeAuthService::class)->issueToken($player); + + $this->withHeader('Authorization', 'Bearer '.$oldToken) + ->putJson('/api/v1/player/password', [ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'password_confirmation' => 'new-secret', + ]) + ->assertOk() + ->assertJsonPath('data.password_changed', true); + + $player->refresh(); + expect(Hash::check('new-secret', (string) $player->password_hash))->toBeTrue() + ->and($player->native_token_version)->toBe(1); + + $this->withHeader('Authorization', 'Bearer '.$oldToken) + ->getJson('/api/v1/player/me') + ->assertStatus(401) + ->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value); + + $this->postJson('/api/v1/player/auth/login', array_merge([ + 'username' => 'password_change_user', + 'password' => 'old-secret', + ], playerLoginCaptcha())) + ->assertStatus(401) + ->assertJsonPath('code', ErrorCode::PlayerCredentialsInvalid->value); + + $this->postJson('/api/v1/player/auth/login', array_merge([ + 'username' => 'password_change_user', + 'password' => 'new-secret', + ], playerLoginCaptcha())) + ->assertOk() + ->assertJsonPath('data.player.id', $player->id); +}); + +test('native player password change validates current password', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native:password-current', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'password_current_user', + 'password_hash' => Hash::make('old-secret'), + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $token = app(PlayerNativeAuthService::class)->issueToken($player); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/player/password', [ + 'current_password' => 'wrong-secret', + 'password' => 'new-secret', + 'password_confirmation' => 'new-secret', + ]) + ->assertStatus(422) + ->assertJsonPath('msg', 'The current password is incorrect.'); + + expect($player->fresh()->native_token_version)->toBe(0) + ->and(Hash::check('old-secret', (string) $player->fresh()->password_hash))->toBeTrue(); +}); + +test('sso player cannot use native password management', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'site_player_id' => 'sso-no-password', + 'auth_source' => PlayerAuthSource::MAIN_SITE_SSO, + 'funding_mode' => PlayerFundingMode::WALLET, + 'username' => 'sso_no_password', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->putJson('/api/v1/player/password', [ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'password_confirmation' => 'new-secret', + ]) + ->assertStatus(422) + ->assertJsonPath('msg', 'Main-site SSO players do not use a lottery password.'); +});