Commit fb97035e authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(orgs): applications decide through decide_application; org staff stay in their org

org-applications: approve and reject are one call each to
decide_application() (El3ab docs/redeem-contract.md 7.5), which adds or
upgrades the membership, runs any grants a code application was holding and
records the staff reviewer in reviewed_by_admin, all in one transaction. The
player is notified only after it answers ok, with the org's name from that
answer in title_ar/body_ar and the reviewer's note or reason in the body.
The old code wrote missing columns and an admin id into a profiles key, so
nothing was ever approved, and then told the player they were in. Bulk
approve and reject go through the same path (up to 50) and say how many
failed and why. The list and show pages read el3ab_organizations (the org
column was blank for all 13), with per-status counts and the cancelled
status. The proof is shown through a ten-minute signed link, from the
private org-proofs bucket when the file is there (core/ProofDocument.php),
and a proof still sitting in the old public bucket is flagged. Signed links
now carry the apikey Kong requires.

Org scoping: core/OrgScope.php, called by the Router before every route,
keeps an org-scoped staff account (admin_users.org_id) inside its own org:
the org in organizations/{id}/... and {orgId}, the owner org of any record
named in the path (application, code, event, channel, frame, transfer, ...),
and org ids in the query or form are all checked with Auth::canAccessOrg;
lists are pinned to the account's org, the org directory goes straight to
it, and modules the role has no permission for at all are refused.
OrgPermissions uses player_id and status='active', ranks moderator with
arbiter, and treats an org-scoped staff account as the owner of its own org
only, as the database functions do.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 9679dd19
<?php
/**
* Org roles and what each may do.
*
* Two kinds of actor:
* - a manager session (admin_users). Platform staff (superadmin, admin, or no
* org_id) may do everything everywhere. An org-scoped staff account acts as
* the owner of its own org and of no other (El3ab docs/redeem-contract.md
* 6.2), which is also how the database functions treat it.
* - a player, by their org_members row: player_id, and only status 'active'
* counts. This class used to query org_members.user_id and .status (only
* the first did not exist, but the query failed either way), so it never
* granted anything to anyone but the superadmin.
*
* Rank order matches the player app (includes/org-perms.php): owner 4, admin 3,
* arbiter and moderator 2, member 1; an unknown role ranks as member.
*/
class OrgPermissions
{
private static array $roleHierarchy = [
'owner' => 4,
'admin' => 3,
'arbiter' => 2,
'moderator' => 2,
'member' => 1,
];
......@@ -17,13 +34,13 @@ class OrgPermissions
'treasury.view', 'treasury.deposit', 'treasury.withdraw',
'announcements.manage', 'events.manage', 'content.manage',
'rosters.manage', 'training.manage', 'media.manage',
'invites.manage', 'applications.review',
'invites.manage', 'applications.review', 'proofs.view',
'achievements.manage', 'spotlights.manage',
'recruitment.manage', 'loyalty.manage',
'settings.view',
'settings.view', 'settings.manage',
],
'arbiter' => [
'members.view', 'members.invite',
'members.view',
'chat.moderate',
'treasury.view',
'announcements.view', 'events.view', 'events.create',
......@@ -31,8 +48,13 @@ class OrgPermissions
'rosters.view',
'training.view', 'training.create',
'media.view', 'media.upload',
'applications.view',
'spotlights.view',
'tournaments.manage',
],
'moderator' => [
'members.view', 'members.mute', 'members.kick',
'chat.view', 'chat.send', 'chat.moderate',
'announcements.view', 'events.view', 'content.view', 'media.view',
],
'member' => [
'members.view',
......@@ -45,53 +67,29 @@ class OrgPermissions
],
];
/** May the signed-in manager account do $permission in this org? */
public static function check(string $orgId, string $permission): bool
{
$session = $_SESSION['user'] ?? null;
if (!$session) {
return false;
}
if (($session['role'] ?? '') === 'superadmin') {
return true;
}
$db = Database::getInstance();
$member = $db->selectOne('org_members', [
'org_id' => "eq.{$orgId}",
'user_id' => "eq.{$session['id']}",
'status' => 'eq.active',
]);
if (!$member) {
return false;
}
return self::roleHasPermission($member['role'], $permission);
$role = self::sessionRole($orgId);
return $role !== null && self::roleHasPermission($role, $permission);
}
public static function requireAccess(string $orgId): void
/** The role a manager session holds in this org, or null for none. */
public static function sessionRole(string $orgId): ?string
{
$session = $_SESSION['user'] ?? null;
if (!$session) {
http_response_code(403);
exit('Access denied');
}
if (($session['role'] ?? '') === 'superadmin') {
return;
}
$db = Database::getInstance();
$member = $db->selectOne('org_members', [
'org_id' => "eq.{$orgId}",
'user_id' => "eq.{$session['id']}",
'status' => 'eq.active',
]);
if (!$session) return null;
if (in_array($session['role'] ?? '', ['superadmin', 'admin'], true)) return 'owner';
if (empty($session['org_id'])) return null;
return $session['org_id'] === $orgId ? 'owner' : null;
}
if (!$member) {
public static function requireAccess(string $orgId): void
{
if (self::sessionRole($orgId) === null) {
http_response_code(403);
exit('Access denied');
View::render('errors/403');
exit;
}
}
......@@ -99,35 +97,29 @@ class OrgPermissions
{
if (!self::check($orgId, $permission)) {
http_response_code(403);
exit('Insufficient permissions');
View::render('errors/403');
exit;
}
}
public static function getRolePermissions(string $role): array
{
return self::$rolePermissions[$role] ?? [];
return self::$rolePermissions[$role] ?? self::$rolePermissions['member'];
}
/** May this player (an active member) do $permission in the org? */
public static function memberCan(string $orgId, string $playerId, string $permission): bool
{
$db = Database::getInstance();
$member = $db->selectOne('org_members', [
'org_id' => "eq.{$orgId}",
'user_id' => "eq.{$playerId}",
'status' => 'eq.active',
]);
if (!$member) {
return false;
}
$customPerms = $member['permissions'] ?? [];
if (is_string($customPerms)) {
$customPerms = json_decode($customPerms, true) ?? [];
}
if (in_array($permission, $customPerms, true)) {
return true;
$member = self::activeMember($orgId, $playerId);
if (!$member) return false;
$custom = $member['permissions'] ?? [];
if (is_string($custom)) $custom = json_decode($custom, true) ?? [];
// Per-member overrides are an object {"kick": true}; older rows hold a list.
if (is_array($custom)) {
if (array_is_list($custom) && in_array($permission, $custom, true)) return true;
$short = explode('.', $permission)[0];
if (!array_is_list($custom) && (($custom[$permission] ?? null) === true || ($custom[$short] ?? null) === true)) return true;
}
return self::roleHasPermission($member['role'], $permission);
......@@ -135,35 +127,32 @@ class OrgPermissions
public static function getMemberRole(string $orgId, string $playerId): ?string
{
$db = Database::getInstance();
$member = $db->selectOne('org_members', [
'org_id' => "eq.{$orgId}",
'user_id' => "eq.{$playerId}",
'status' => 'eq.active',
]);
return self::activeMember($orgId, $playerId)['role'] ?? null;
}
return $member['role'] ?? null;
public static function rank(?string $role): int
{
return self::$roleHierarchy[$role ?? ''] ?? 1;
}
public static function isRoleHigherOrEqual(string $role, string $targetRole): bool
{
$roleLevel = self::$roleHierarchy[$role] ?? 0;
$targetLevel = self::$roleHierarchy[$targetRole] ?? 0;
return $roleLevel >= $targetLevel;
return self::rank($role) >= self::rank($targetRole);
}
private static function roleHasPermission(string $role, string $permission): bool
private static function activeMember(string $orgId, string $playerId): ?array
{
$perms = self::$rolePermissions[$role] ?? [];
if (in_array('*', $perms, true)) {
return true;
}
if (in_array($permission, $perms, true)) {
return true;
}
return Database::getInstance()->selectOne('org_members', [
'org_id' => "eq.{$orgId}",
'player_id' => "eq.{$playerId}",
'status' => 'eq.active',
]);
}
private static function roleHasPermission(string $role, string $permission): bool
{
$perms = self::$rolePermissions[$role] ?? self::$rolePermissions['member'];
if (in_array('*', $perms, true) || in_array($permission, $perms, true)) return true;
$domain = explode('.', $permission)[0] ?? '';
return in_array("{$domain}.*", $perms, true);
}
......
<?php
/**
* Keeps an org-scoped staff account (admin_users.org_id set, roles org_admin /
* org_manager) inside its own organisation.
*
* Auth::canAccessOrg() existed but nothing called it, so such an account could
* open any other org's pages, or its members, codes, chat and treasury, by
* editing the id in the URL. The Router now calls OrgScope::enforce() before
* every org route, which:
*
* - checks the org named in the path (organizations/{id}/..., {orgId});
* - checks the org that owns any other record named in the path (an
* application, a code, an event, a frame, a transfer...), looked up in its
* table, so a foreign record cannot be reached through one's own org id;
* - checks org ids posted or passed in the query (org_id, to_org_id, ...);
* - pins list pages to the account's org ($_GET['org_id']), and sends the
* org directory straight to the account's own org;
* - refuses, for these accounts, every module their role has no permission
* for at all in config/permissions.php (settings, users, branding, ...).
*
* Platform staff (superadmin, admin, and any account without org_id) are not
* affected.
*/
class OrgScope
{
/** Path parameter => [table, org columns]. 'id' depends on the module. */
private const PARAM_OWNERS = [
'memberId' => ['org_members', ['org_id']],
'inviteId' => ['redeem_codes', ['org_id']],
'codeId' => ['redeem_codes', ['org_id']],
'eventId' => ['org_events', ['org_id']],
'annId' => ['org_announcements', ['org_id']],
'channelId' => ['org_chat_channels', ['org_id']],
'messageId' => ['org_chat_messages', ['org_id']],
'contentId' => ['org_content', ['org_id']],
'rewardId' => ['org_loyalty_rewards', ['org_id']],
'mediaId' => ['org_media', ['org_id']],
'rosterId' => ['org_rosters', ['org_id']],
'spotId' => ['org_member_spotlights', ['org_id']],
'sessionId' => ['org_training_sessions', ['org_id']],
'redemptionId' => ['redeem_redemptions', ['code_id']],
];
/** Modules whose {id} is a record of their own table, not an org. */
private const ID_OWNERS = [
'org-applications' => ['org_membership_applications', ['org_id']],
'org-achievements' => ['org_achievements', ['org_id']],
'org-frames' => ['profile_frames', ['org_id']],
'org-challenges' => ['org_challenges', ['challenger_org_id', 'challenged_org_id']],
'org-transfers' => ['org_player_transfers', ['from_org_id', 'to_org_id']],
'org-recruitment' => ['org_recruitment_posts', ['org_id']],
'org-partnerships' => ['org_partnerships', ['org_a_id', 'org_b_id']],
];
/** Request fields that name an org. */
private const ORG_FIELDS = ['org_id', 'org', 'to_org_id', 'from_org_id', 'challenged_org_id', 'challenger_org_id', 'org_a_id', 'org_b_id', 'partner_org_id'];
public static function isOrgModule(string $module): bool
{
return $module === 'organizations' || str_starts_with($module, 'org-');
}
public static function enforce(string $module, string $pattern, array $params): void
{
if (!Auth::isOrgScoped() || in_array(Auth::role(), ['superadmin', 'admin'], true)) return;
$own = Auth::orgId();
if (!self::roleTouchesModule($module)) self::deny();
if (!self::isOrgModule($module)) return;
// The org directory: an org account has exactly one org.
if ($module === 'organizations' && $pattern === 'organizations') {
header('Location: /organizations/' . rawurlencode($own));
exit;
}
if ($module === 'organizations' && in_array($pattern, ['organizations/create', 'organizations/store'], true)) self::deny();
$orgs = [];
foreach ($params as $name => $value) {
if ($name === 'id' && str_starts_with($pattern, 'organizations/{id}')) {
$orgs[] = $value;
} elseif ($name === 'orgId') {
$orgs[] = $value;
} elseif ($name === 'id' && isset(self::ID_OWNERS[$module])) {
$owners = self::owners(self::ID_OWNERS[$module], $value);
if ($owners === null) continue; // not found: the controller says so
if (!in_array($own, $owners, true)) self::deny();
} elseif (isset(self::PARAM_OWNERS[$name])) {
[$table, $cols] = self::PARAM_OWNERS[$name];
if ($table === 'redeem_redemptions') {
$codeIds = self::owners(self::PARAM_OWNERS[$name], $value);
$owners = $codeIds ? self::owners(['redeem_codes', ['org_id']], $codeIds[0]) : null;
} else {
$owners = self::owners([$table, $cols], $value);
}
if ($owners === null) continue;
if (!in_array($own, $owners, true)) self::deny();
}
}
foreach (self::ORG_FIELDS as $f) {
foreach ([$_GET[$f] ?? null, $_POST[$f] ?? null] as $v) {
if (is_string($v) && $v !== '' && $f !== 'to_org_id' && $f !== 'challenged_org_id' && $f !== 'org_b_id' && $f !== 'partner_org_id') {
$orgs[] = $v;
}
}
}
foreach ($orgs as $o) {
if (!Auth::canAccessOrg((string)$o)) self::deny();
}
// Lists without an org in the path show the account's org only.
$_GET['org_id'] = $own;
$_GET['org'] = $own;
}
/**
* Add the account's org to a list query; $cols are the org columns (any
* of them may match). A no-op for platform staff.
*/
public static function filter(array &$query, array $cols): void
{
if (!Auth::isOrgScoped() || in_array(Auth::role(), ['superadmin', 'admin'], true)) return;
$own = Auth::orgId();
if (count($cols) === 1) {
$query[$cols[0]] = 'eq.' . $own;
} else {
$query['or'] = '(' . implode(',', array_map(fn($c) => "{$c}.eq.{$own}", $cols)) . ')';
}
}
/** Org ids that own a record, or null when the record does not exist. */
private static function owners(array $spec, string $id): ?array
{
[$table, $cols] = $spec;
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id)) return null;
$row = Database::getInstance()->selectOne($table, ['id' => 'eq.' . $id, 'select' => implode(',', $cols)]);
if (!$row) return null;
return array_values(array_filter(array_map(fn($c) => $row[$c] ?? null, $cols)));
}
/** Has the role any permission at all on this module? */
private static function roleTouchesModule(string $module): bool
{
if (in_array($module, ['dashboard'], true)) return true;
$permissions = require BASE_PATH . '/config/permissions.php';
foreach ($permissions[Auth::role()] ?? [] as $p) {
if ($p === '*' || $p === $module || str_starts_with($p, $module . '.')) return true;
}
return false;
}
private static function deny(): void
{
http_response_code(403);
View::render('errors/403');
exit;
}
}
<?php
/**
* Membership proofs (ID cards, receipts) attached to org applications.
*
* The old player app stored them in the PUBLIC `assets` bucket under
* `org-proofs/...` and saved a public URL in org_membership_applications
* .document_url, so anyone with the link could read a national ID. New proofs
* go to the private `org-proofs` bucket (El3ab docs/redeem-api.md 4.2, 6.5),
* and only staff see them, through a link signed for ten minutes.
*
* document_url may therefore hold:
* - a storage path in the private bucket ("org-proofs/<uid>/<file>" or "<uid>/<file>"),
* - a storage URL (public, sign or authenticated form) in any bucket,
* - anything else (an external link), shown as it is.
* The manager signs whatever it can with the service key, preferring the
* private bucket, and only falls back to the stored URL when nothing signs.
*/
class ProofDocument
{
public const PRIVATE_BUCKET = 'org-proofs';
public const TTL = 600;
/**
* @return array{url:?string, expires_at:?string, signed:bool, private:bool, legacy_public:bool}
*/
public static function resolve(?string $documentUrl, int $ttl = self::TTL): array
{
$none = ['url' => null, 'expires_at' => null, 'signed' => false, 'private' => false, 'legacy_public' => false];
$raw = trim((string)$documentUrl);
if ($raw === '') return $none;
$candidates = [];
$legacyPublic = false;
if (preg_match('#/storage/v1/object/(?:public/|sign/|authenticated/)?([^/?]+)/([^?]+)#', $raw, $m)) {
$bucket = $m[1];
$path = rawurldecode($m[2]);
$legacyPublic = str_contains($raw, '/object/public/');
// A file moved into the private bucket keeps its name below org-proofs/.
if (($pos = strpos($path, self::PRIVATE_BUCKET . '/')) !== false) {
$candidates[] = [self::PRIVATE_BUCKET, substr($path, $pos + strlen(self::PRIVATE_BUCKET) + 1)];
}
$candidates[] = [$bucket, $path];
} elseif (!preg_match('#^https?://#i', $raw)) {
$path = ltrim($raw, '/');
if (str_starts_with($path, self::PRIVATE_BUCKET . '/')) $path = substr($path, strlen(self::PRIVATE_BUCKET) + 1);
$candidates[] = [self::PRIVATE_BUCKET, $path];
}
$storage = SupabaseStorage::getInstance();
foreach ($candidates as [$bucket, $path]) {
try {
$url = $storage->getSignedUrl($bucket, self::encodePath($path), $ttl);
} catch (RuntimeException $e) {
continue;
}
return [
'url' => $url,
'expires_at' => gmdate('c', time() + $ttl),
'signed' => true,
'private' => $bucket === self::PRIVATE_BUCKET,
'legacy_public' => $legacyPublic && $bucket !== self::PRIVATE_BUCKET,
];
}
if (preg_match('#^https?://#i', $raw)) {
return ['url' => $raw, 'expires_at' => null, 'signed' => false, 'private' => false, 'legacy_public' => $legacyPublic];
}
return $none;
}
private static function encodePath(string $path): string
{
return implode('/', array_map('rawurlencode', explode('/', $path)));
}
}
......@@ -21,6 +21,7 @@ class Router
$config = $match['config'];
$params = $match['params'];
$pattern = $match['pattern'];
$module = $config['module'];
$action = $config['action'];
......@@ -49,6 +50,9 @@ class Router
Auth::requireAuth();
// An org-scoped staff account stays inside its own org, whatever the URL says.
OrgScope::enforce($module, $pattern, $params);
$controllerPath = MODULES_PATH . "/{$module}/controller.php";
if (!file_exists($controllerPath)) {
http_response_code(404);
......@@ -78,7 +82,7 @@ class Router
private function matchRoute(string $route): ?array
{
if (isset($this->routes[$route])) {
return ['config' => $this->routes[$route], 'params' => []];
return ['config' => $this->routes[$route], 'params' => [], 'pattern' => $route];
}
foreach ($this->routes as $pattern => $config) {
......@@ -89,7 +93,7 @@ class Router
if (preg_match($regex, $route, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
return ['config' => $config, 'params' => $params];
return ['config' => $config, 'params' => $params, 'pattern' => $pattern];
}
}
......
......@@ -104,7 +104,13 @@ class SupabaseStorage
}
$data = json_decode($response, true);
return SUPABASE_URL . '/storage/v1' . ($data['signedURL'] ?? '');
$signed = (string)($data['signedURL'] ?? $data['signedUrl'] ?? '');
if ($signed === '') {
throw new RuntimeException('Failed to generate signed URL');
}
// Kong in front of storage refuses any request without an apikey, the
// token in the signed URL included; the anon key is public by design.
return SUPABASE_URL . '/storage/v1' . $signed . (str_contains($signed, '?') ? '&' : '?') . 'apikey=' . SUPABASE_ANON_KEY;
}
public function validateFile(array $file, array $allowedMimes, int $maxSizeBytes): ?string
......
......@@ -16,7 +16,9 @@ require_once __DIR__ . '/core/Response.php';
require_once __DIR__ . '/core/ThemeService.php';
require_once __DIR__ . '/core/SupabaseStorage.php';
require_once __DIR__ . '/core/OrgPermissions.php';
require_once __DIR__ . '/core/OrgScope.php';
require_once __DIR__ . '/core/EconomyService.php';
require_once __DIR__ . '/core/ProofDocument.php';
$route = trim($_GET['route'] ?? '', '/');
$method = $_SERVER['REQUEST_METHOD'];
......
This diff is collapsed.
......@@ -10,17 +10,17 @@
<!-- Filters -->
<div class="flex items-center gap-4 mb-5">
<div class="filter-pills">
<a href="/org-applications" class="filter-pill <?= empty($status) ? 'active' : '' ?>">الكل</a>
<a href="/org-applications?status=pending<?= $orgId ? "&org={$orgId}" : '' ?>" class="filter-pill <?= $status === 'pending' ? 'active' : '' ?>">معلق</a>
<a href="/org-applications?status=approved<?= $orgId ? "&org={$orgId}" : '' ?>" class="filter-pill <?= $status === 'approved' ? 'active' : '' ?>">مقبول</a>
<a href="/org-applications?status=rejected<?= $orgId ? "&org={$orgId}" : '' ?>" class="filter-pill <?= $status === 'rejected' ? 'active' : '' ?>">مرفوض</a>
<a href="/org-applications?status=<?= $orgId ? "&org={$orgId}" : '' ?>" class="filter-pill <?= empty($status) ? 'active' : '' ?>">الكل</a>
<?php foreach (OrgApplicationsController::STATUS as $key => [$label]): ?>
<a href="/org-applications?status=<?= $key ?><?= $orgId ? "&org={$orgId}" : '' ?>" class="filter-pill <?= $status === $key ? 'active' : '' ?>"><?= $label ?> <span class="tabular-nums">(<?= (int)($counts[$key] ?? 0) ?>)</span></a>
<?php endforeach; ?>
</div>
<select class="form-select" style="width: auto; padding: var(--space-2) var(--space-4);" onchange="location.href='/org-applications?status=<?= urlencode($status) ?>&org='+this.value">
<option value="">كل المنظمات</option>
<?php foreach ($organizations as $org): ?>
<option value="<?= $org['id'] ?>" <?= $orgId === $org['id'] ? 'selected' : '' ?>>
<?= View::e($org['name_ar'] ?? $org['name']) ?>
<?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?>
</option>
<?php endforeach; ?>
</select>
......@@ -57,7 +57,7 @@
<th>المنظمة</th>
<th>الحالة</th>
<th>تاريخ التقديم</th>
<th>المستندات</th>
<th>المستند</th>
<th>الإجراءات</th>
</tr>
</thead>
......@@ -85,30 +85,18 @@
</div>
</td>
<td>
<div class="font-medium"><?= View::e($app['organization']['name_ar'] ?? $app['organization']['name'] ?? '-') ?></div>
<?php $o = $app['organization']; ?>
<div class="font-medium"><?= View::e($o ? ((($o['name_ar'] ?? '') ?: $o['name'])) : '-') ?></div>
</td>
<td>
<?php
$statusBadges = ['pending' => 'warning', 'approved' => 'success', 'rejected' => 'danger'];
$statusLabels = ['pending' => 'معلق', 'approved' => 'مقبول', 'rejected' => 'مرفوض'];
?>
<span class="badge badge-<?= $statusBadges[$app['status']] ?? 'default' ?> badge-dot">
<?= $statusLabels[$app['status']] ?? $app['status'] ?>
</span>
<?php [$sl, $sb] = OrgApplicationsController::STATUS[$app['status']] ?? [$app['status'], 'default']; ?>
<span class="badge badge-<?= $sb ?> badge-dot"><?= View::e($sl) ?></span>
</td>
<td class="text-xs tabular-nums"><?= date('Y-m-d H:i', strtotime($app['created_at'])) ?></td>
<td class="text-xs tabular-nums"><?= Time::show($app['created_at'] ?? null) ?></td>
<td>
<?php
$docs = $app['proof_documents'] ?? [];
if (is_string($docs)) {
$docs = json_decode($docs, true) ?? [];
}
$docCount = count($docs);
?>
<?php if ($docCount > 0): ?>
<span class="badge badge-info"><?= $docCount ?> مستند</span>
<?php else: ?>
<span class="text-muted text-xs">-</span>
<span class="text-xs"><?= View::e(OrgApplicationsController::DOC_TYPES[$app['document_type'] ?? ''] ?? ($app['document_type'] ?: '-')) ?></span>
<?php if (!empty($app['document_url'])): ?>
<span class="badge badge-info badge-sm">مرفق</span>
<?php endif; ?>
</td>
<td>
......@@ -117,7 +105,7 @@
<?php if (($app['status'] ?? '') === 'pending'): ?>
<form method="POST" action="/org-applications/<?= $app['id'] ?>/approve" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success btn-sm" title="قبول">
<button type="submit" class="btn btn-success btn-sm" title="قبول" aria-label="قبول" onclick="return confirm('قبول هذا الطلب وإضافة اللاعب للمنظمة؟')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
</button>
</form>
......@@ -155,7 +143,7 @@
<div class="modal-content">
<div class="modal-header">
<h3>رفض الطلب</h3>
<button class="btn btn-icon btn-ghost" onclick="closeRejectModal()">&times;</button>
<button class="btn btn-icon btn-ghost" onclick="closeRejectModal()" aria-label="إغلاق"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<form method="POST" id="rejectForm">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
......@@ -177,7 +165,7 @@
<div class="modal-content">
<div class="modal-header">
<h3>رفض الطلبات المحددة</h3>
<button class="btn btn-icon btn-ghost" onclick="closeBulkRejectModal()">&times;</button>
<button class="btn btn-icon btn-ghost" onclick="closeBulkRejectModal()" aria-label="إغلاق"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<form method="POST" action="/org-applications/bulk-reject" id="bulkRejectForm">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
......
This diff is collapsed.
......@@ -18,6 +18,7 @@ class OrgChallengesController
$queryParams['status'] = "eq.{$status}";
}
OrgScope::filter($queryParams, ['challenger_org_id', 'challenged_org_id']);
$countParams = $queryParams;
unset($countParams['select'], $countParams['order']);
$total = $this->db->count('org_challenges', $countParams);
......
......@@ -20,6 +20,7 @@ class OrgPartnershipsController
$queryParams['status'] = 'eq.' . $_GET['status'];
}
OrgScope::filter($queryParams, ['org_a_id', 'org_b_id']);
$countParams = $queryParams;
unset($countParams['select'], $countParams['order']);
$total = $this->db->count('org_partnerships', $countParams);
......
......@@ -18,6 +18,7 @@ class OrgRecruitmentController
$queryParams['status'] = "eq.{$status}";
}
OrgScope::filter($queryParams, ['org_id']);
$countParams = $queryParams;
unset($countParams['select'], $countParams['order']);
$total = $this->db->count('org_recruitment_posts', $countParams);
......
......@@ -18,6 +18,7 @@ class OrgTransfersController
$queryParams['status'] = "eq.{$status}";
}
OrgScope::filter($queryParams, ['from_org_id', 'to_org_id']);
$countParams = $queryParams;
unset($countParams['select'], $countParams['order']);
$total = $this->db->count('org_player_transfers', $countParams);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment