Commit af3c054a authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(orgs): the manager is where organisations are run, end to end

The mobile app has no org management (owner decision 2026-10-26), so the
organizations module now covers everything an org owner, admin or staff needs:

- Settings: name, description, tag (2-5, unique, case-blind), sticker hue
  (the game's families only), join policy, games, member invites; logo and
  banner to the public org-logos / org-banners buckets (type, size and
  minimum dimensions checked before anything is saved; a replaced or removed
  image is deleted from storage).
- Platform decisions (staff only): capacity tier (free 500, event 5,000,
  partner unlimited) or an explicit cap never below the member count,
  verification badge, suspend / lift suspension through org_set_suspended
  (revokes every live code in the same call, reason required), soft delete.
- Members: add by username, role change, mute for a period or until lifted,
  kick (status left, never deleted), ban with a reason (closes the player's
  pending applications through decide_application), unban, reactivate; the
  last active owner cannot be kicked, banned or demoted; the 5-org limit,
  capacity and bans are refused as the database functions refuse them. Each
  action notifies the player (org_* bell types) and lands in
  org_activity_log, shown on the org page in Arabic.
- Reports against orgs (org_reports): a platform queue and a per-org tab,
  with status and resolution.

OrgScope now also checks the action, not just the module, for org-scoped
accounts: org_manager can look, draft and create codes but cannot change
members or settings, decide applications, revoke or reverse codes, or
publish. The views hide what the role cannot do.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 59a746e4
......@@ -140,6 +140,8 @@ return [
// Organizations
'organizations' => ['module' => 'organizations', 'action' => 'list'],
'organizations/create' => ['module' => 'organizations', 'action' => 'create'],
'organizations/reports' => ['module' => 'organizations', 'action' => 'reports'],
'organizations/reports/{reportId}/update' => ['module' => 'organizations', 'action' => 'reportUpdate'],
'organizations/store' => ['module' => 'organizations', 'action' => 'store'],
'organizations/{id}' => ['module' => 'organizations', 'action' => 'show'],
'organizations/{id}/edit' => ['module' => 'organizations', 'action' => 'edit'],
......@@ -149,6 +151,8 @@ return [
'organizations/{id}/members' => ['module' => 'organizations', 'action' => 'members'],
'organizations/{id}/members/add' => ['module' => 'organizations', 'action' => 'addMember'],
'organizations/{id}/members/{memberId}/remove' => ['module' => 'organizations', 'action' => 'removeMember'],
'organizations/{id}/members/{memberId}/action' => ['module' => 'organizations', 'action' => 'memberAction'],
'organizations/{id}/suspend' => ['module' => 'organizations', 'action' => 'suspend'],
// Economy
'economy' => ['module' => 'economy', 'action' => 'index'],
......
......@@ -17,7 +17,8 @@
* - 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, ...).
* for at all in config/permissions.php (settings, users, branding, ...),
* and the org-changing actions their role lacks (ACTION_PERMS).
*
* Platform staff (superadmin, admin, and any account without org_id) are not
* affected.
......@@ -53,6 +54,35 @@ class OrgScope
'org-partnerships' => ['org_partnerships', ['org_a_id', 'org_b_id']],
];
/**
* For the modules that change an org, the permission an org-scoped role
* needs per action (config/permissions.php). org_admin holds them all;
* org_manager can look, draft and create codes, but cannot change members,
* settings, decide applications, revoke or reverse codes, or publish.
* Actions not listed need only some permission on the module.
*/
private const ACTION_PERMS = [
'organizations' => [
'edit' => 'organizations.update', 'update' => 'organizations.update',
'addMember' => 'organizations.members', 'memberAction' => 'organizations.members', 'removeMember' => 'organizations.members',
'verify' => 'organizations.verify', 'suspend' => 'organizations.delete', 'delete' => 'organizations.delete',
'reports' => 'organizations.verify', 'reportUpdate' => 'organizations.verify',
],
'org-applications' => [
'approve' => 'org-applications.approve', 'reject' => 'org-applications.approve',
'bulkApprove' => 'org-applications.approve', 'bulkReject' => 'org-applications.approve',
],
'org-invites' => [
'create' => 'org-invites.create', 'store' => 'org-invites.create', 'bulk' => 'org-invites.create', 'bulkStore' => 'org-invites.create',
'revoke' => 'org-invites.delete', 'reverse' => 'org-invites.delete',
],
'org-announcements' => [
'create' => 'org-announcements.create', 'store' => 'org-announcements.create',
'edit' => 'org-announcements.update', 'update' => 'org-announcements.update',
'publish' => 'org-announcements.update', 'unpublish' => 'org-announcements.update', 'delete' => 'org-announcements.delete',
],
];
/** 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'];
......@@ -61,12 +91,13 @@ class OrgScope
return $module === 'organizations' || str_starts_with($module, 'org-');
}
public static function enforce(string $module, string $pattern, array $params): void
public static function enforce(string $module, string $pattern, array $params, string $action = ''): void
{
if (!Auth::isOrgScoped() || in_array(Auth::role(), ['superadmin', 'admin'], true)) return;
$own = Auth::orgId();
if (!self::roleTouchesModule($module)) self::deny();
if (($need = self::ACTION_PERMS[$module][$action] ?? null) && !Auth::hasPermission($need)) self::deny();
if (!self::isOrgModule($module)) return;
// The org directory: an org account has exactly one org.
......
......@@ -51,7 +51,7 @@ class Router
Auth::requireAuth();
// An org-scoped staff account stays inside its own org, whatever the URL says.
OrgScope::enforce($module, $pattern, $params);
OrgScope::enforce($module, $pattern, $params, $action);
$controllerPath = MODULES_PATH . "/{$module}/controller.php";
if (!file_exists($controllerPath)) {
......
<?php $canDecide = !Auth::isOrgScoped() || Auth::hasPermission('org-applications.approve'); // org_manager can look, not decide ?>
<div class="content-header">
<div class="flex items-center gap-4">
<h1>طلبات العضوية</h1>
......@@ -35,7 +36,7 @@
</div>
<?php else: ?>
<!-- Bulk Actions Bar -->
<div class="bulk-actions-bar" id="bulkActionsBar" style="display: none;">
<div class="bulk-actions-bar" id="bulkActionsBar" style="display: none;"<?= $canDecide ? '' : ' data-locked="1"' ?>>
<div class="flex items-center gap-3">
<span class="text-sm"><span id="selectedCount">0</span> محدد</span>
<button type="button" class="btn btn-success btn-sm" onclick="bulkApprove()">
......@@ -102,7 +103,7 @@
<td>
<div class="flex items-center gap-2">
<a href="/org-applications/<?= $app['id'] ?>" class="btn btn-ghost btn-sm">عرض</a>
<?php if (($app['status'] ?? '') === 'pending'): ?>
<?php if ($canDecide && ($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="قبول" aria-label="قبول" onclick="return confirm('قبول هذا الطلب وإضافة اللاعب للمنظمة؟')">
......@@ -194,6 +195,7 @@ function updateBulkBar() {
const checked = document.querySelectorAll('.row-check:checked');
const bar = document.getElementById('bulkActionsBar');
const count = document.getElementById('selectedCount');
if (bar.dataset.locked) return;
if (checked.length > 0) {
bar.style.display = 'block';
count.textContent = checked.length;
......
<?php $canDecide = !Auth::isOrgScoped() || Auth::hasPermission('org-applications.approve'); // org_manager can look, not decide ?>
<?php
[$statusLabel, $statusBadge] = OrgApplicationsController::STATUS[$application['status']] ?? [$application['status'], 'default'];
$orgName = $organization ? ((($organization['name_ar'] ?? '') ?: $organization['name'])) : null;
......@@ -142,7 +143,9 @@ $fileIcon = '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke=
<?php endif; ?>
</div>
<?php if ($application['status'] === 'pending'): ?>
<?php if ($application['status'] === 'pending' && !$canDecide): ?>
<div class="card"><p class="text-sm text-muted">الطلب بانتظار قرار مدير المنظمة أو إدارة المنصة.</p></div>
<?php elseif ($application['status'] === 'pending'): ?>
<div class="grid grid-2">
<div class="card">
<div class="card-header"><h3 class="card-title">قبول الطلب</h3></div>
......
......@@ -7,6 +7,7 @@
[$sl, $sb] = RedeemService::STATUS[$code['status']];
$f = $stats['funnel'] ?? [];
$csrf = Auth::csrfToken();
$canRevoke = !Auth::isOrgScoped() || Auth::hasPermission('org-invites.delete'); // org_manager can look, not revoke or reverse
$funnel = [
'opens' => 'فتح الرابط', 'previews' => 'معاينات', 'signups' => 'حسابات جديدة', 'redemptions' => 'استخدامات',
'pending' => 'بانتظار الموافقة', 'rejected' => 'مرفوضة', 'reversed' => 'أُلغيت', 'first_game' => 'لعبوا مباراة', 'first_tournament' => 'دخلوا بطولة',
......@@ -87,7 +88,7 @@ $elig = is_string($code['eligibility'] ?? null) ? json_decode($code['eligibility
<td><?= !empty($r['was_new_signup']) ? '<span class="badge badge-success badge-sm">جديد</span>' : '<span class="text-muted text-xs">لا</span>' ?></td>
<td><span class="badge badge-<?= $rb ?> badge-sm"><?= View::e($rl) ?></span><?php if (!empty($r['reverse_reason'])): ?><div class="text-xs text-muted"><?= View::e($r['reverse_reason']) ?></div><?php endif; ?></td>
<td>
<?php if (in_array($r['status'], ['applied', 'partial', 'pending_approval'], true)): ?>
<?php if ($canRevoke && in_array($r['status'], ['applied', 'partial', 'pending_approval'], true)): ?>
<form method="POST" action="<?= $base ?>/<?= $code['id'] ?>/redemptions/<?= $r['id'] ?>/reverse" class="flex gap-2 items-center">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="text" name="reason" class="form-input" style="max-width: 160px; padding: var(--space-1) var(--space-2); font-size: 12px;" placeholder="السبب" required>
......@@ -102,7 +103,7 @@ $elig = is_string($code['eligibility'] ?? null) ? json_decode($code['eligibility
<?php endif; ?>
</div>
<?php if ($code['status'] !== 'revoked'): ?>
<?php if ($canRevoke && $code['status'] !== 'revoked'): ?>
<div class="card">
<h3 class="card-title mb-3">إلغاء الكود</h3>
<form method="POST" action="<?= $base ?>/<?= $code['id'] ?>/revoke" class="flex gap-3 items-end flex-wrap">
......
<div class="content-header">
<h1>التصنيف الموسمي</h1>
</div>
<!-- Filters -->
<div class="card mb-5">
<form method="GET" action="/org-leaderboards/seasonal" class="flex gap-4 items-end flex-wrap">
<div class="form-group" style="margin-bottom:0;">
<label class="form-label">الموسم</label>
<input type="text" name="season" class="form-input" value="<?= View::e($season ?? '') ?>" placeholder="e.g. 2024-Q1" dir="ltr">
</div>
<button type="submit" class="btn btn-primary">تصفية</button>
</form>
</div>
<!-- Seasonal Rankings Table -->
<div class="data-table-wrapper">
<?php if (empty($rankings)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 21h8m-4-4v4m-4.5-8.5L12 8l4.5 4.5M3 3h18v4H3z"/></svg>
<h3 class="empty-state-title">لا توجد تصنيفات</h3>
<p class="empty-state-text">لم يتم تسجيل أي تصنيفات موسمية<?= !empty($season) ? ' لهذا الموسم' : '' ?></p>
</div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th style="width: 60px;">المركز</th>
<th>المنظمة</th>
<th>النقاط</th>
<th>فوز مباريات</th>
<th>خسارة مباريات</th>
<th>فوز بطولات</th>
<th>فوز تحديات</th>
<th>نشاط الأعضاء</th>
<th>التغيير</th>
</tr>
</thead>
<tbody>
<?php foreach ($rankings as $index => $ranking): ?>
<?php
$rank = $pagination->offset + $index + 1;
$org = $ranking['el3ab_organizations'] ?? null;
$orgName = $org['name_ar'] ?? $org['name'] ?? '-';
$previousRank = $ranking['previous_rank'] ?? null;
$rankChange = $previousRank ? ($previousRank - $rank) : 0;
?>
<tr>
<td>
<?php if ($rank <= 3): ?>
<span class="badge <?= $rank === 1 ? 'badge-warning' : ($rank === 2 ? 'badge-default' : 'badge-info') ?>">#<?= $rank ?></span>
<?php else: ?>
<span class="text-muted">#<?= $rank ?></span>
<?php endif; ?>
</td>
<td>
<div class="flex items-center gap-2">
<?php if (!empty($org['logo_url'])): ?>
<img src="<?= View::e($org['logo_url']) ?>" alt="" style="width:24px;height:24px;border-radius:4px;">
<?php endif; ?>
<span><?= View::e($orgName) ?></span>
</div>
</td>
<td><strong class="tabular-nums"><?= number_format($ranking['total_points'] ?? 0) ?></strong></td>
<td class="tabular-nums"><?= number_format($ranking['matches_won'] ?? 0) ?></td>
<td class="tabular-nums"><?= number_format($ranking['matches_lost'] ?? 0) ?></td>
<td class="tabular-nums"><?= number_format($ranking['tournaments_won'] ?? 0) ?></td>
<td class="tabular-nums"><?= number_format($ranking['challenges_won'] ?? 0) ?></td>
<td class="tabular-nums"><?= number_format($ranking['member_activity_score'] ?? 0) ?></td>
<td>
<?php if ($rankChange > 0): ?>
<span style="color: var(--success);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;"><polyline points="18 15 12 9 6 15"/></svg>
<?= $rankChange ?>
</span>
<?php elseif ($rankChange < 0): ?>
<span style="color: var(--danger);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;"><polyline points="6 9 12 15 18 9"/></svg>
<?= abs($rankChange) ?>
</span>
<?php elseif ($previousRank !== null): ?>
<span class="text-muted">-</span>
<?php else: ?>
<span class="badge badge-info">جديد</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="table-footer">
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&season=<?= urlencode($season ?? '') ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
This diff is collapsed.
This diff is collapsed.
......@@ -11,6 +11,8 @@
<a href="/organizations" class="filter-pill <?= empty($filter) ? 'active' : '' ?>">الكل</a>
<a href="/organizations?filter=verified" class="filter-pill <?= $filter === 'verified' ? 'active' : '' ?>">موثقة</a>
<a href="/organizations?filter=unverified" class="filter-pill <?= $filter === 'unverified' ? 'active' : '' ?>">غير موثقة</a>
<a href="/organizations?filter=suspended" class="filter-pill <?= $filter === 'suspended' ? 'active' : '' ?>">موقوفة</a>
<a href="/organizations/reports" class="filter-pill">البلاغات<?= !empty($openReports) ? ' (' . (int)$openReports . ')' : '' ?></a>
</div>
<div class="data-table-wrapper">
......@@ -41,7 +43,8 @@
<th>الشعار</th>
<th data-sort="name">الاسم</th>
<th data-sort="name_ar">الاسم العربي</th>
<th data-sort="country_code">الدولة</th>
<th>الوسم</th>
<th>الأعضاء</th>
<th>التحقق</th>
<th>الحالة</th>
<th style="width: 60px;">الإجراءات</th>
......@@ -63,14 +66,9 @@
<div class="font-medium"><?= View::e($org['name']) ?></div>
<div class="text-xs text-muted"><?= View::e($org['slug'] ?? '') ?></div>
</td>
<td><?= View::e($org['name_ar'] ?? '-') ?></td>
<td>
<?php if (!empty($org['country_code'])): ?>
<span class="badge badge-default"><?= View::e($org['country_code']) ?></span>
<?php else: ?>
-
<?php endif; ?>
</td>
<td><?= View::e(($org['name_ar'] ?? '') ?: '-') ?></td>
<td><?= !empty($org['tag']) ? '<span class="badge badge-default" dir="ltr">' . View::e($org['tag']) . '</span>' : '-' ?></td>
<td class="tabular-nums"><?= (int)($org['member_count'] ?? 0) ?> / <?= $org['max_members'] === null ? 'بلا حد' : (int)$org['max_members'] ?></td>
<td>
<?php if ($org['is_verified'] ?? false): ?>
<span class="badge badge-success verification-badge">
......@@ -82,10 +80,12 @@
<?php endif; ?>
</td>
<td>
<?php if ($org['is_active'] ?? true): ?>
<?php if (!empty($org['suspended_at'])): ?>
<span class="badge badge-danger badge-dot">موقوفة</span>
<?php elseif ($org['is_active'] ?? true): ?>
<span class="badge badge-success badge-dot">نشطة</span>
<?php else: ?>
<span class="badge badge-danger badge-dot">معطلة</span>
<span class="badge badge-danger badge-dot">محذوفة</span>
<?php endif; ?>
</td>
<td>
......@@ -95,10 +95,9 @@
</button>
<div class="dropdown-menu">
<a href="/organizations/<?= $org['id'] ?>" class="dropdown-item">عرض</a>
<a href="/organizations/<?= $org['id'] ?>/edit" class="dropdown-item">تعديل</a>
<?php if (!($org['is_verified'] ?? false)): ?>
<button class="dropdown-item" onclick="verifyOrg('<?= $org['id'] ?>', '<?= View::e($org['name']) ?>')">توثيق</button>
<?php endif; ?>
<a href="/organizations/<?= $org['id'] ?>/edit" class="dropdown-item">الإعدادات</a>
<a href="/organizations/<?= $org['id'] ?>?tab=members" class="dropdown-item">الأعضاء</a>
<a href="/organizations/<?= $org['id'] ?>/invites" class="dropdown-item">أكواد الدعوة</a>
<div class="dropdown-divider"></div>
<button class="dropdown-item danger" onclick="confirmDelete('/organizations/<?= $org['id'] ?>/delete', '<?= View::e($org['name']) ?>')">حذف</button>
</div>
......
<?php
/**
* Reports against orgs, across the platform (feature H3).
* Expects: $reports, $orgs, $reporters, $status
*/
$csrf = Auth::csrfToken();
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/organizations" class="btn btn-icon btn-ghost" aria-label="رجوع">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1>بلاغات المنظمات</h1>
</div>
</div>
<div class="filter-pills mb-5">
<a href="/organizations/reports?status=all" class="filter-pill <?= $status === 'all' ? 'active' : '' ?>">الكل</a>
<?php foreach (OrganizationsController::REPORT_STATUS as $k => [$l]): ?>
<a href="/organizations/reports?status=<?= $k ?>" class="filter-pill <?= $status === $k ? 'active' : '' ?>"><?= $l ?></a>
<?php endforeach; ?>
</div>
<p class="text-sm text-muted mb-4">يبلّغ اللاعبون عن المنظمات من التطبيق. لإيقاف منظمة (وإلغاء كل أكوادها) افتح صفحتها.</p>
<div class="data-table-wrapper">
<?php if (empty($reports)): ?>
<div class="empty-state"><h3 class="empty-state-title">لا توجد بلاغات</h3><p class="empty-state-text">لا شيء بهذه الحالة</p></div>
<?php else: ?>
<table class="data-table">
<thead><tr><th>المنظمة</th><th>المبلّغ</th><th>السبب</th><th>التفاصيل</th><th>الحالة</th><th>الوقت</th><th>المراجعة</th></tr></thead>
<tbody>
<?php foreach ($reports as $r): ?>
<?php $o = $orgs[$r['org_id']] ?? null; $rp = $reporters[$r['reporter_id'] ?? ''] ?? null; [$rl, $rb] = OrganizationsController::REPORT_STATUS[$r['status']] ?? [$r['status'], 'default']; ?>
<tr>
<td><?php if ($o): ?><a href="/organizations/<?= $o['id'] ?>?tab=reports" class="text-link"><?= View::e((($o['name_ar'] ?? '') ?: $o['name'])) ?></a><?php if (!empty($o['suspended_at'])): ?> <span class="badge badge-danger badge-sm">موقوفة</span><?php endif; ?><?php else: ?>-<?php endif; ?></td>
<td><?= $rp ? View::e($rp['display_name'] ?: $rp['username']) : '-' ?></td>
<td><?= View::e(OrganizationsController::REPORT_REASONS[$r['reason']] ?? $r['reason']) ?></td>
<td class="text-sm"><?= View::e($r['details'] ?? '') ?><?php if (!empty($r['resolution'])): ?><div class="text-xs text-muted">القرار: <?= View::e($r['resolution']) ?></div><?php endif; ?></td>
<td><span class="badge badge-<?= $rb ?> badge-sm"><?= $rl ?></span></td>
<td class="text-xs tabular-nums"><?= Time::show($r['created_at'] ?? null) ?></td>
<td>
<form method="POST" action="/organizations/reports/<?= $r['id'] ?>/update" class="flex gap-1">
<input type="hidden" name="_csrf" value="<?= $csrf ?>"><input type="hidden" name="back" value="/organizations/reports?status=<?= View::e($status) ?>">
<select name="status" class="form-select" style="width:auto; font-size:12px;" aria-label="الحالة"><?php foreach (OrganizationsController::REPORT_STATUS as $k => [$l]): ?><option value="<?= $k ?>" <?= $r['status'] === $k ? 'selected' : '' ?>><?= $l ?></option><?php endforeach; ?></select>
<input type="text" name="resolution" class="form-input" style="width: 150px; padding: var(--space-1) var(--space-2); font-size: 12px;" placeholder="القرار">
<button type="submit" class="btn btn-xs btn-ghost">حفظ</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
This diff is collapsed.
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