البداية
حسابات المنصّة: مَن يفتح تطبيقك
النطاقات تجيب «أي بيانات يقرأ تطبيقك». وهذا القسم يجيب سؤالاً آخر تماماً: مَن الجالس أمام الشاشة. المنصّة تعرف أربعة حسابات، وهي لا تعمل بآلية واحدة — وهذا الفرق هو ما يكسر افتراضات أكثر من أي شيء آخر في التكامل.
| الحساب | أين يُسجَّل | النطاق (scope) |
الجهة في السياق | كيف تخاطبه |
|---|---|---|---|---|
| معلّم / إداري | tenant_employees + دور داخل الجهة |
tenant |
tenant_id |
"roles": ["teacher"] |
| طالب | tenant_students |
general |
portal_tenant_id فقط |
"portal": "student" |
| وليّ أمر | tenant_guardians |
general |
portal_tenant_id فقط |
"portal": "guardian" |
| مفوَّض من وليّ أمر | صفّ تفويض — لا صفّ عضوية له | general |
portal_tenant_id فقط |
"portal": "guardian-delegate" |
١. الموظّف — دور داخل الجهة
المعلّم والإداري يعيشان داخل الجهة: صفّ في tenant_employees يحمل
type (وظيفته)، وعضوية في الجهة، ودور. الثلاثة لازمة: العضوية تُظهِر الجهة
في منتقي السياق، والدور هو ما يُدخِله فعلاً.
والموظّف نوع واحد: تسجّله المدرسة معلّماً أو إدارياً — لا الاثنين، ولا
«موظّفاً» بجانب أحدهما، فكلاهما موظّف أصلاً — فيُمنَح دور teacher أو
administrator: دور بلا أي صلاحية، مفتاح باب لا أكثر. ما يحقّ له فعله يُقرَّر
بعدها بإلحاق صلاحيات بالدور أو بوضعه في مجموعة إدارية.
داخل تطبيقك هذا هو الحساب «العاديّ»: Tenant::current() مضبوطة، والنطاق
tenant، وكل ما تعرفه عن سياق الجهة صحيح.
المطابقة أوسع مما تظنّ. "roles": ["teacher"] تُطابَق على ثلاثة أشياء
معاً: الدور النشط، وكل الأدوار المختارة في الجلسة، وtenant_employees.type
نفسه. كلّها تُطبَّع قبل المقارنة (حروف صغيرة، والمسافات و_ تصير -)،
فـ"Teacher" و"teacher" سواء.
٢ و٣. الطالب ووليّ الأمر — بوّابتان لا دوران
هنا يتغيّر كل شيء. الطالب ووليّ الأمر ليس لهما صفّ دور إطلاقاً: اسم
البوّابة هو الجمهور نفسه. والنطاق general، وtenant_id في السياق فارغ،
والجهة تعيش في مفتاح آخر: portal_tenant_id.
وهذا ليس تفصيلاً في التسمية. حساب البوّابة لا يملك سياق جهة بالمعنى
المعتاد. المنصّة تُقلِع جهته لهذا الطلب وحده، وعلى صفحات التطبيق التي
أعلنها بيانك فقط؛ خارجها يبقى بلا جهة، وصفحات المنصّة المرتبطة بجهة تظلّ
ترفضه. فأيّ كود يفترض أن Tenant::current() موجودة دائماً سيسقط على صفحة
بوّابة.
ووليّ الأمر عابر للجهات: قد يكون له أبناء في مدرستين، فيختار الجهة عند الدخول — وتلك الجهة هي التي تحملها القائمة الجانبية وتُفتَح بها التطبيقات.
٤. المفوَّض من وليّ أمر — بوّابة مستقلّة، لا نكهة من وليّ الأمر
وليُّ الأمر يستطيع أن يفوّض شخصاً على شؤون أبنائه: قريباً يستلم طفلاً من البوّابة، أو سائقاً. والتفويض ليس حساباً موازياً لوليّ الأمر، بل شريحةٌ من صلاحياته هو:
- على أبناء بأعيانهم — يسمّيهم وليُّ الأمر واحداً واحداً، لا «كل أبنائي».
- بجزء من صلاحياته — يختار ما يُسلِّمه (النداء، متابعة الحضور…).
- وغالباً إلى تاريخ — تفويضٌ لأسبوع الاختبارات ينتهي بانتهائه.
والمفوَّض لا صفّ عضوية له إطلاقاً: لا tenant_guardians ولا
tenant_students. كلُّ وجوده في المدرسة صفُّ تفويض، فيختفي وحده حين ينتهي
التفويض أو يُسحَب أو يفقد وليُّ الأمر نفسه ارتباطه بالطالب.
ولماذا بوّابة مستقلّة ولم يُلحَق بوليّ الأمر: تطبيقٌ يخدم الآباء لا يخدم
بذلك كلَّ من سلّمه أبٌ مفتاحاً — عليه أن يقول إنه يخدمه. فلو دخل المفوَّض
بمفتاح guardian لطابق كلَّ جمهور وليّ أمر منشور اليوم بلا أن يعلن أحدٌ
شيئاً. والثمن صريح: تطبيقك لا يبلغ المفوَّضين حتى تُعلن هذا الجمهور وتنشر.
"account_types": [
{ "key": "guardian", "kind": "dependent", "portal": "guardian",
"web_route": "call.family", "mobile_entry": "okta_app/webview/family.html" },
{ "key": "guardian-delegate", "kind": "dependent", "portal": "guardian-delegate",
"web_route": "call.delegate", "mobile_entry": "okta_app/webview/delegate.html" }
]
والمنصّة تفرض الحدود، وأنت ترسمها. لا يقع عليك أن ترفض: نداءات قراءة
الطلاب في سياق مفوَّض مقيَّدة خادمياً بأبناء تفويضه — ListStudents تعيد
هؤلاء وحدهم، وGetStudent لطالب خارجهم تجيب بالـ404 نفسه الذي يجيب به معرّفٌ
مجهول (كي لا يتعلّم المفوَّض من الرفض من غيرُهم مسجَّل)، وقائمة أبناء وليّ
الأمر الذي فوّضه تعود بأبناء التفويض لا بعائلته كلّها. وتفويضٌ انتهى لا
يصل شيئاً: يُقيَّد إلى لا أحد، لا يُفكّ قيده.
وما ترسمه أنت هو الزرّ: $viewer في سياق مفوَّض يحمل
| ما تقرؤه | ماذا يعني |
|---|---|
$viewer->type / accountType |
guardian_delegate |
$viewer->isDelegate() |
true — وهو الفرع الذي ترسم عليه شاشة المفوَّض |
$viewer->delegatedChildren() |
[{id, capabilities, expiresAt}] — وid هو نفس مُعرّف الطالب الذي تعيده خدمات الطلاب، فتربط عليه مباشرةً |
$viewer->delegatedChildIds() |
المعرّفات وحدها، لترشيح صفوفك أنت |
$viewer->mayForChild($childId, 'okta_hdor.pickup.call') |
هل يحقّ له هذا الفعل لهذا الابن اليوم |
@if ($viewer->isDelegate())
@foreach ($viewer->delegatedChildren() as $child)
{{-- ارسم البطاقة، وأظهر زرّ النداء لمن يملكه وحده --}}
@if ($viewer->mayForChild($child['id'], 'okta_hdor.pickup.call'))
<x-button wire:click="call('{{ $child['id'] }}')">{{ __('نداء') }}</x-button>
@endif
@endforeach
@endif
delegatedChildren()لا تكون فارغة أبداً في سياق مفوَّض: مفوَّضٌ لم يبقَ له شيء ساري يُجاب بـ$viewer === nullكما يُجاب وليُّ أمرٍ فُكّ ارتباطه — فهي قائمةٌ تُرسَم، لا قائمةٌ تُفحَص هل هي فارغة.expiresAtتاريخ انتهاء التفويض (أوnullلتفويض بلا نهاية) — اعرِضه: «ينتهي الخميس» يشرح للمستخدم اختفاءَ الزرّ بعد غد.mayForChildللرسم، وليست هي ما يمنع: المنصّة ترفض الفعل خارج التفويض بنفسها، فشاشةٌ نسيت الفحص لا تستطيع أن تُنفّذ. ولمن ليس مفوَّضاً تجيبfalseدائماً — فاحرس شاشة وليّ الأمر بكونه وليَّ أمر لا بها.
القدرات كلُّها من عندك — المنصّة لا تُعلن واحدة
كل قدرة يستطيع وليُّ الأمر أن يسلّمها لمفوَّض يعلنها تطبيقٌ مثبَّت. لا توجد قائمة منصّة، ولن تجد ما تختار منه.
وكانت توجد: تسع قدرات ثابتة (pickup_student، view_marks، pay_fees
وأخواتها) تُعرَض على كل وليّ أمر في كل مدرسة. ولا يقرؤها شيء — لا شاشة ولا
نقطة نهاية ولا وسيط في المنصّة سأل يوماً هل يحمل المفوَّض واحدةً منها. القارئ
الوحيد كان، ولا يزال، mayForChild() في تطبيقٍ مثبَّت. وأوّل تطبيقٍ احتاج
قدرةً أعلن قدرته بنفسه بدل أن يستعمل pickup_student — فصار مربّعان لفعل
واحد، ولا يعرف الوالد أيَّهما تحترمه المدرسة. فالمفردات صارت لمن يفرضها.
ما يعنيه لك عملياً: جهةٌ لا يُعلن أيُّ تطبيق مثبَّت فيها قدرةً لا تعرض على وليّ الأمر أيّ مربّع، والتفويض يبقى معناه «هذا الشخص يصل هؤلاء الأبناء». فإن أردت أن يستطيع وليُّ الأمر تسليم شيءٍ من تطبيقك، أعلِنه — وإلا فلا شيء يُسلَّم.
فأعلِنها في manifest.json، توأماً عائلياً لـrbac_permissions:
"guardian_capabilities": [
{ "key": "okta_hdor.pickup.call",
"label": { "ar": "إجراء النداء", "en": "Place the pickup call" } },
{ "key": "okta_hdor.attendance.read",
"label": { "ar": "عرض حضور الابن", "en": "View attendance" },
"gates": ["okta_hdor.attendance.read"] }
]
- المفتاح
<feature>.<resource>.<action>وfeatureهو slug تطبيقك بصيغة snake (okta-hdor→okta_hdor) — نفس قاعدةrbac_permissionsوبنفس الحدّة. والسبب ليس ترتيباً: المفتاح يُخزَّن على تفويض الأسرة ويُقرأ بحسب التطبيق، فإعلانه في مجال تطبيق آخر يضع خانةً في شاشة وليّ الأمر نيابةً عن شريك آخر، تحت عنوانٍ يقرؤه الوالد على أنه من المنصّة. المفتاح خارج مجالك يُرفض بالاسم. label.arإلزامي — هو كلُّ ما يقرأه وليُّ الأمر بجانب الخانة، وخانةٌ بلا عنوان لا يستطيع أحد أن يضغطها بصدق. وlabel.enيسقط إلى العربية.- المدرسة لا تمنحها ولا تمنعها ولا تُستأذَن فيها. وليُّ الأمر يحملها كلَّها بصفته وليَّ أمر في تلك الجهة، والقرار الوحيد قراره هو: أيّها يسلّم. والحدّ الذي بقي هو ارتباط وليّ الأمر بالابن نفسه — ابنٌ لم يعد وليُّ أمره يصله يسقط من التفويض كلّه.
- والمدرسة تقرأ ما جرى ولا تتدخّل فيه: صفحة «المفوَّضون» في لوحة الجهة تسرد من فوّض ومن فُوِّض وعلى أيّ أبناء وبأيّ قدرات وإلى متى — قراءةٌ فقط.
- وتظهر لوليّ الأمر مجموعةً باسم تطبيقك في شاشة التفويض، عند كل جهة ثبّتته. والحدّ يبقى: التفويض نفسه، وتاريخ انتهائه، والأبناء المسمَّون فيه.
واقرأها وقت التشغيل بالدالة نفسها:
if ($viewer->mayForChild($childId, 'okta_hdor.pickup.call')) {
// ارسم زرّ النداء لهذا الابن
}
- والمفوَّض لا يُفوَّض على نفسه: مفتاحٌ يسمّي ابناً حسابُه هو حسابُ المفوَّض يُرفض عند الإنشاء وعند التعديل، ولا يُعرَض في منتقي الأبناء أصلاً.
وgates اختيارية، وتركها فارغةً هو الحال العادي: تقرأ القدرة بنفسك وترسم
عليها، ولا يتغيّر شيء لأي تطبيق منشور. وحين تسمّي فيها نطاقاتك أنت، تنتقل
بوّابة الرفض إلى المنصّة: نداءٌ يستعمل ذلك النطاق في سياق مفوَّض لم يُمنح
القدرة يُرفض قبل أن يصل كودك. اختيارٌ لكل قدرة على حدة، فلا شيء سبق نشره
يتبدّل سلوكه.
والكتلة تصل الإصدار من مستودعك بزرّ «مزامنة من manifest» أو بأداة
sync_from_manifest، وتُقرأ وتُكتب أيضاً بـget_guardian_capabilities و
set_guardian_capabilities. وغائبةً لا تُمَسّ: مستودعٌ توقّف عن ذكر المفتاح
لا يسحب بصمت قدراتٍ عُرضت على الأسر فعلاً.
كيف تُعلن جمهورك
تبويب «أنواع الحسابات» في محرّر الإصدار — أو set_account_types عبر MCP
— وهو ما يصير menu.audiences[] في okta-web وmobile.audiences[] في تطبيق
أوكتا. لكل نوع:
"account_types": [
{ "key": "teacher", "kind": "primary", "roles": ["teacher"], "web_route": "school-app.staff" },
{ "key": "guardian", "kind": "dependent", "portal": "guardian", "web_route": "school-app.family" }
]
kind—primary(الجمهور الأساسي) أوdependent(تابع).- هدف واحد فقط:
roles(أدوار داخل الجهة) أوportal(بوّابة). - سطح واحد على الأقل:
web_routeو/أوmobile_entry.
وweb_route اسم مسار Laravel لا رابطاً ولا /path — وحدّ فضاء أسماء:
school-app.admin يغطّي school-app.admin.reports ولا يغطّي
school-app.admins. شكل المقاطع يحمل معنى، فلا تكتبه اعتباطاً.
الهدف الثالث: permissions[] — من منحته المدرسة، لا من خمّنتَ دوره
أسماء الأدوار في roles[] تخمينك لما تسمّيه المدارس، والمدرسة تنشئ أدوارها
المخصّصة وتربط بها موظّفيها: «محاسبة»، «شؤون الموظفين»، «مشرف الحضور». فموظّفةٌ
نوع حسابها administrator وأُعطيت صلاحياتِ الإدارة عن طريق دور مخصّص كانت
تُوجَّه إلى شاشة الإداريين نفسها، لأن المشغّل كان يختار المسار بالدور وحده.
فصار للجمهور هدفٌ ثالث: الصلاحية.
"account_types": [
{ "key": "management", "kind": "primary", "permissions": ["okta_hr.management.view"], "web_route": "okta-hr.admin" },
{ "key": "administrator", "kind": "dependent", "roles": ["administrator"], "web_route": "okta-hr.staff" }
]
- يطابق من يحمل أيّاً من الأسماء المذكورة داخل الجهة — تقرؤها المنصّة من الجداول وقت الطلب، سواء جاءت من دوره الأساسي أو من دور مخصّص أنشأته المدرسة وربطته به. لا يعنيك اسم الدور ولا اختلافه بين مدرسة وأخرى.
- الأسماء صلاحياتك أنت بصيغتها الكاملة
<feature>.<resource>.<action>، وأعلِنها فيrbac_permissionsكذلك — وإلا لم تستطع المدرسة منح أحد إياها. - الترتيب هو القرار: المشغّل يربط البطاقة بـأول جمهور مطابق (الأساسي قبل التبعي، ثم ترتيب الإعلان). فضَع جمهور الصلاحية قبل جماهير الأدوار ليغلب عند من يطابق الاثنين.
- هدف مستقلّ: لا يجتمع مع
roles[]ولا معportal(مستخدم البوّابة لا يحمل صلاحية في الجهة أصلاً)، ويحتاجweb_route. - أوكتا ويب وحدها: تطبيق أوكتا يطابق بطاقاته بالدور والبوّابة، وحاملُ
الصلاحية يصله سطحُ الموظّفين العامّ هناك — فلا
mobile_entryعلى هذا النوع.
وهذا لا يُغني عن can() داخل الشاشة: الجمهور يقرّر أين يدخل، والصلاحية
تقرّر ما يراه ويفعله بعد الدخول.
نوع الجهة: entity_types — شاشة مدير المجمّع غير شاشة مدير المدرسة
مدير الجهة في مجمّع يدير مدارس، ومدير الجهة في مدرسة يدير طلاباً: نوع الحساب
واحد (tenant-admin) والشاشة ليست واحدة. وكان الطريق الوحيد حزمةً واحدة تسأل
عند الفتح عن نوع الجهة التي هي فيها ثم تتفرّع — فتُنزَّل شاشة المجمّع وتُترجَم
على هاتف كل مدرسة. الآن تُعلن صفّين للنوع نفسه، والمنصّة تختار بنوع الجهة
التي يقف فيها صاحبه:
"account_types": [
{ "key": "tenant-admin", "roles": ["tenant-admin"],
"web_route": "okta-hr.dashboard",
"mobile_entry": "okta_app/native/main/lib/main.dart" },
{ "key": "tenant-admin", "roles": ["tenant-admin"],
"entity_types": ["complex", "education_company"],
"web_route": "okta-hr.group",
"mobile_entry": "okta_app/native/group/lib/main.dart" }
]
وفي manifest.json هو entityTypes على الجمهور، في menu.audiences[]
وmobile.audiences[] كليهما:
{ "key": "tenant-admin@complex+education_company", "kind": "primary",
"roles": ["tenant-admin"], "entityTypes": ["complex", "education_company"],
"entry": "okta_app/native/group/lib/main.dart" }
- الأضيق يغلب لا الأسبق. حيث ينطبق الصفّ المقيَّد يغلب صفَّ النوع نفسه بلا أنواع أيّاً كان ترتيب الإعلان، والصفّ بلا أنواع يخدم كل جهة أخرى. فالشكل المعتاد صفّان: عامّ لكل الجهات، ومقيَّد حيث تحتاج شاشةً خاصّة.
- بلا
entity_types= كل أنواع الجهات، وهو حال كل تطبيق نُشر قبل الحقل: لا يتغيّر بيانه بايتاً، ولا يتغيّر ما يراه أحد. - المفردات مغلقة:
individual_teacher،school،complex،college،university،institute،academy،education_company،daycare_center. والاسم المجهول يُرفَض بالاسم ولا يُسقَط: إسقاطه يضيّق الصفّ بصمت، وإسقاط آخر اسمٍ فيه يوسّعه إلى كل الجهات — عكس ما كُتب تماماً. - للموظّفين وحدهم: على
rolesوpermissions. ويُرفَض على البوّابة: الطالب ووليّ الأمر والمفوَّض عابرون للجهات (أبناءٌ في مدرستين)، ولا يُختار جمهورهم بالمكان الذي يقفون فيه. - نوع حساب واحد في نوع جهة واحد = شاشة واحدة: صفّان للنوع نفسه يسمّيان النوع نفسه من الجهات يُرفضان على السطح نفسه، لأن الثاني شاشة لا يصلها أحد هناك. والسطحان مستقلّان: يجوز أن تضيّق أوكتا ويب ولا تضيّق تطبيق أوكتا.
- المفتاح
<الدور>@<الأنواع>(tenant-admin@complex+education_company): يشتقّه المحرّر وset_account_typesلأن النوع الواحد صار صفّين. ومعset_account_typesمرّر الدور وحده أو المفتاح المشتقّ. - نوع الجهة يُقرأ من سجلّها لا من الطلب: لا ترويسة ولا معامل يغيّر أيّ شاشة تُفتح. وجهةٌ نوعها مجهول للمنصّة (سجلّ أقدم من المفردات) لا يبلغها إلا الصفّ العامّ.
- بلا رفع عقد ولا تحديث لتطبيق أوكتا: الخادم يختار، والهاتف يستلم مدخلاً
واحداً كما يستلمه اليوم. ولكل صفّ مقيَّد مدخله وأرضيته (
min_contract) وصفوفversions[]الخاصة به. - وحامل الصلاحية (من يدخل بصلاحية من تطبيقك لا بدوره) يُحسَب في الجهة التي يقف فيها: صفٌّ للمجمّعات لا يسلب المدارس شاشتها الوحيدة.
والمحاكي يعمل على بيئة تجربة من نوع «مدرسة»، فيركّب مدخل المدرسة ويسرد الصفوف المقيَّدة بأسمائها. جرّب شاشة المجمّع على بيئة تجربة من نوع «مجمع تعليمي» (يُختار النوع عند تجهيز البيئة) وادخل إليها من الهاتف.
الفخّ: يُنشَر بنجاح ثم لا يعمل أبداً
مفتاح بوّابة داخل roles[]. "roles": ["student"] يبدو سليماً تماماً —
student مفتاح معروف في الكتالوج — لكن المنصّة تطابق مستخدم البوّابة على حقل
portal وحده ولا تطابقه أبداً على أسماء الأدوار. فالإعلان لا يطابق
أحداً: يُنشَر الإصدار، ويبدو البيان صحيحاً، ثم تُرفض كل صفحات ذلك الجمهور لكل
مستخدم. و"custom": true لا تُنقذه — البوّابة ليست دوراً تعرّفه الجهة،
والمنصّة ترفض custom على جمهور portal أصلاً.
ومفتاح دور مخترَع أو مكتوب خطأً ("staff" مثلاً) يفشل بنفس الطريقة
الصامتة. الجهات تعرّف أدوارها الخاصة، فالمفتاح خارج الكتالوج مقبول — لكن
بإعلان صريح: "custom": true. أما بدونها فمرفوض عمداً، لأن البديل أن
تكتشف الخطأ من شكوى مدرسة لا من رسالة نشر.
الفرض حقيقي لا إخفاء
EnsureAppAudience مُطبَّقة على مجموعة web كاملةً — بلا تعاون منك — و
كذلك على نداءات Livewire عبر persistent middleware تُعيد الفحص على مسار
الصفحة الأصلية. فحراسة الصفحة وحدها كانت ستترك النوافذ مفتوحة: كل فعل بعد
الرسم نداء POST إلى مسار آخر.
والبوّابة ترفض بالافتراض. قبلها كان المُشغِّل يخفي الرابط في قائمة التطبيقات فقط، ومَن يعرف المسار ويكتبه في شريط العنوان كان يُخدَم.
مَن يفتح تطبيقك الآن؟ — ULID المستخدم
معرفة نوع الحساب لا تكفي: التطبيق يحتاج أن يعرف أيّ شخص بعينه ليعرض له ما يخصّه هو — حصص هذا المعلّم، أبناء وليّ الأمر هذا.
use App\Services\PartnerApi\Identity\GetCurrentViewer;
$viewer = app(GetCurrentViewer::class)();
if ($viewer === null) {
return; // لا مستخدم من الأنواع الثلاثة يشاهد الآن.
}
$viewer->type; // employee | student | guardian
$viewer->id; // ULID — وهو ما تخزّنه وتربط به
$viewer->displayName;
$viewer->roles; // مفاتيح الأدوار الخام — للموظّف وحده
$viewer->entityId; // ULID الجهة
$viewer->accountType; // tenant_admin | administrator | teacher | employee | student | guardian
$viewer->groups; // [{key, title}] المجموعات الإدارية التي صنعتها المدرسة — للعرض
$viewer->hasStaffRecord(); // هل للشخص صفّ في دليل موظفي الجهة؟
موظّفٌ بلا صفّ في دليل الموظفين — hasStaffRecord()
في كل جهة موظّفون لم تُدخلهم المدرسة في دليلها: الحساب الذي سجّل المدرسة
(لا أحد يضيف المالك إلى الدليل)، ومن أُلحق بالجهة مباشرةً، وحسابا بيئة
التجربة اللذان تعمل عليهما أنت. هؤلاء يصلك عنهم viewer كامل — نوع الحساب،
والمجموعات، والصلاحيات، والجهة — لكن id يعود فارغاً و
hasStaffRecord() تجيب false، لأن لا صفّ في الدليل يُربَط به.
فاقرأها قبل الربط على id وحده؛ أما can() وaccountType فتُسألان بلا
تفكير. (قبل هذا كان الجواب null عن هؤلاء جميعاً، فكانت الواجهة ميّتة في
بيئة التجربة نفسها.)
لا تستعمل auth()->id() لهذا
الفاحص لا يمنعها، لكنها مفتاح رقمي داخلي للمنصّة، وقاعدة العقد أن المعرّفات الرقمية لا تعبر حدّ الشريك أبداً. والأهمّ أنها لا تفيدك: هي الرقم نفسه سواء دخل الشخص معلّماً أو وليَّ أمر، فلا تُطابِق شيئاً مما تملكه.
id يُطابِق ما تُرجِعه خدمات القراءة — وهذا كلّ الفائدة
| النوع | id هو نفسه |
|---|---|
student |
id في StudentDto |
employee |
id في EmployeeDto |
guardian |
id في GuardianDto |
فالربط مباشر بلا تحويل:
$mine = MyAppRecord::query()->where('student_ulid', $viewer->id)->get();
ولاحظ أن هذه المعرّفات تأتي من جداول مختلفة عمداً: الطالب يُعرَّف بصفّ تسجيله، ووليّ الأمر بسجلّ مستخدمه. فلا تحاول اشتقاق المعرّف بنفسك من أي شيء آخر — اقرأه من هنا.
type هي الهويّة التي دخل بها، لا كل ما هو عليه
إنسان واحد قد يكون معلّماً في المدرسة ووليَّ أمر لطالب فيها. يدخل بإحداهما، وهذه تُخبرك بأيّهما دخل — وهي نفس الهويّة التي بنت عليها بوّابة الجماهير قرارها بخدمة الصفحة. فتطبيق يقرّر بـ«هل هذا الشخص وليّ أمر في مكان ما؟» سيعرض للمعلّم شاشة وليّ الأمر.
null ليست خطأ
تعني: لا أحد مسجَّل دخوله، أو أن المُشاهِد ليس من الأنواع الثلاثة — مشرف المنصّة يفتح صفحتك مثال على ذلك. اعرض حالة فارغة مفهومة، لا خطأً.
لا شيء منها يُقرَأ من الطلب
الهويّة تُحلّ في الخادم من سياق الجلسة. لا تقبل معرّف مستخدم من الواجهة ولا من query string: مُشاهِد يستطيع المُنادي تسميته هو مُشاهِد يستطيع تزويره.
نوع الحساب شيء، وما منحته المدرسة شيء آخر
هذا أكثر ما يُخلَط في التكامل، فيُقال صراحةً. أمام شاشتك شخصان قد يبدوان واحداً: إداريّ وشخص مُنح صلاحيات تحت مسمّى «مدير الموارد البشرية». الأول نوع حساب — كلمة المنصّة، ومعناها واحد في كل مدرسة. والثاني مجموعة صلاحيات صنعتها هذه المدرسة في محرّر أدوارها، وسمّتها كما شاءت، ووضعت فيها ما شاءت من الصلاحيات — والمدرسة التالية تسمّي الوظيفة نفسها اسماً آخر، أو لا تملكها أصلاً.
| نوع الحساب | مجموعة الصلاحيات / الصلاحية | |
|---|---|---|
| من يقرّره | المنصّة — مفردات مغلقة | المدرسة — تخترعه وتسمّيه |
| القيم | tenant_admin (مدير الجهة)، administrator (إداري)، teacher (معلّم)، student، guardian |
«مدير الموارد البشرية»، «مشرف الحضور»… بلا حصر |
| كم واحداً للشخص | واحد — الموظّف إمّا معلّم وإمّا إداري، وكلاهما موظّف أصلاً | أيّ عدد، فوق نوع الحساب |
| أين تقرؤه | $viewer->accountType |
$viewer->groups للعرض، و$viewer->can() للقرار |
| ما تبني عليه | الشاشة — بوّابة الأسرة أم سطح الموظّفين، وجمهور audiences[] |
الفعل — هل يرى هذا الزرّ، هل يستطيع هذا الحفظ |
المجموعة ليست حساباً ولا نوع حساب
المستخدم يدخل بحساب واحد. مجموعة الصلاحيات لا تُنشئ حساباً ولا نوع حساب؛
هي تضيف صلاحيات فوق ما يحمله. فالمحاسِبة تدخل بحسابها، ونوع حسابها
administrator كأي إداري، والمجموعة أضافت لها ما أضافت. فلا تسأل «بأي حساب
دخل؟» — اسأل «لماذا يحمل هذه الصلاحية؟»، وللسؤال جواب جاهز في الجدول
أدناه.
ولذلك تحمل الـDTO حقلين لا واحداً، ولكلٍّ سؤاله:
$viewer->type |
$viewer->accountType |
|
|---|---|---|
| يجيب عن | الهويّة التي دخل بها | نوع الحساب الثابت |
| القيم | employee · student · guardian |
tenant_admin · administrator · teacher · employee · student · guardian |
| يُستعمل لـ | الفصل الكبير: بوّابة أسرة أم سطح موظّفين (isStaff() / isPortal()) |
أيّ سطحٍ من أسطح الموظّفين، وإعلان audiences[] |
وبأشخاص حقيقيين:
| الشخص | type |
accountType |
|---|---|---|
| مديرة المدرسة | employee |
tenant_admin |
| إداري | employee |
administrator |
| إدارية في مجموعة «محاسبة» | employee |
administrator — المجموعة لا تغيّرها |
| معلّم | employee |
teacher |
| موظّف لم تُسجَّل صفته | employee |
employee |
| طالب | student |
student |
| وليّ أمر | guardian |
guardian |
tenant_adminتعلو على غيرها: من يدير الجهة يبقى كذلك مهما حمل.employeeليس نوعاً ثالثاً للموظّف: صفٌّ قديم لم تُسجَّل له صفة. عامله موظّفاً عامّاً وأظهر له ما تسمح به صلاحياته، لا صنفاً خاصّاً.- والدور الذي دخل به شيء ثالث: صفحة اختيار السياق تعرض بطاقةً لكل دور
يحمله الشخص في الجهة — ومجموعة الصلاحيات صفُّ دور، فهي بطاقة مثل غيرها —
ويضغط واحدة، فتكتبها المنصّة تحت اسمه طوال الجلسة. تقرأه في
$viewer->activeRole؛ راجع «الباب الذي دخل منه» أدناه، ولا تخلطه بنوع الحساب: الأول يتغيّر بين جلستين للشخص نفسه، والثاني لا يتغيّر.
الصلاحية لا اسم الدور — الدوال التي تفرّق لك
المشكلة التي جاءت هذه الدوال تحلّها: تطبيقٌ يفرّع على «إداري» يعرض شاشات
الإدارة لكل من نوع حسابه administrator — ومنهم المحاسِب الذي وضعته المدرسة
في مجموعة سمّتها «محاسبة» وأعطته صلاحية واحدة. ولا يستطيع التطبيق أن يفرّع
على «محاسبة» لأن المدرسة التالية تسمّيها «المالية» أو لا تملكها. الجواب
الوحيد الثابت عبر المدارس هو الصلاحية، ولذلك يحمل ViewerDto الآن صلاحيات
تطبيقك التي يحملها هذا الشخص في هذه الجهة — مقروءةً من الجداول لا من
الجلسة، فتجيب على الويب وفي الجوال وفي مهمّة طابور سواء — ومعها مصدر كل
صلاحية:
| الدالة | تجيب عن |
|---|---|
$viewer->permissions |
قائمة صلاحيات تطبيقك التي يحملها (<feature>.<resource>.<action>) |
$viewer->can('x.y.view') / canAny([...]) / canAll([...]) |
هل يحملها — هذا ما تبني عليه كل زرّ وكل شاشة داخلية |
$viewer->accessLevel() |
full (مدير الجهة) · granted (يحمل شيئاً من تطبيقك) · none |
$viewer->isTenantAdmin() / isStaff() / isPortal() / hasGroups() |
من هو، بلا مقارنة نصوص |
$viewer->permissionSources |
خريطة: الصلاحية ← الأدوار التي تحملها (administrator دور أساسي، group.accounting مجموعة صلاحيات، direct منحة مباشرة) |
$viewer->grantedBy('x.y.view') |
لماذا يحملها — أيّ أدوار |
$viewer->grantedByBaseRole('x.y.view') |
يحملها لأنه إداري/معلّم (منحة الدور الأساسي من rbac_permissions) |
$viewer->grantedByGroupOnly('x.y.view') |
يحملها فقط لأن المدرسة أعطتها لمجموعته أو له مباشرة |
$viewer->groupOnlyPermissions() |
ما أضافته هذه المدرسة فوق نوع الحساب |
ViewerDto::isBaseRole($key) / isGroupRole($key) |
هل هذا المفتاح دور منصّة أم مجموعة مدرسة |
مفاتيح المجموعات: مجموعة الصلاحيات مفتاحها
group.<slug>. وكانت تُسمّىadministrator.<slug>فتصادمت الكلمة مع نوع الحسابadministrator، فيعود الشخص إدارياً ومجموعتُه إدارية ولا يعرف قارئ الأدوار أيّهما يقصد. المجموعات القديمة أُعيدت تسميتها، وisGroupRole()تقبل الصيغتين — فلا تقارن البادئة بنفسك، اسأل الدالة.
كيف تُصمَّم شاشتك على ذلك — مثال «الإداري يرى ملفه، والمحاسبة ترى الإدارة»:
- أعلن صلاحياتك في
rbac_permissionsوامنح الدور الأساسي أقلّ ما يلزمه؛ ما تريده بيد المدرسة لا تمنحه لدور أساسي:
المدرسة تفتح محرّر أدوارها وتضع"rbac_permissions": { "administrator": ["okta_hr.profile.view"], "tenant-admin": ["okta_hr.profile.view", "okta_hr.management.view"] }okta_hr.management.viewفي مجموعة «محاسبة» — أو «المالية» — بالعنوان الذي كتبته فيrbac_labels. - افحص الصلاحية لا الدور:
المحاسِب — وهو$viewer = app(GetCurrentViewer::class)(); if ($viewer->isPortal()) { return view('okta-hr::family'); // الشاشة: نوع الحساب } $showProfile = $viewer->can('okta_hr.profile.view'); // الإداري (منحة أساسية) $showManagement = $viewer->can('okta_hr.management.view'); // «محاسبة» أو «المالية» أو مدير الجهة if ($viewer->accessLevel() === 'none') { return view('okta-hr::no-access'); }administratorأيضاً — يرى ملفه والإدارة: الأول لأنه إداري والثاني لأن مدرسته قالت ذلك. إن أردت شاشةً تعرض ما أضافته المدرسة وحده فـgroupOnlyPermissions()تعطيك إياه؛ لكن لا تخفِ ما يحمله الشخص بحقّ لأن مصدره الدور الأساسي. - لا تقارن أبداً
$viewer->groups[*]['key']ولا['title']ولا$viewer->rolesبنصّ. الفاحص لن يمسكها كلها؛ المدرسة الثانية ستمسكها.
وauth()->user()->can() تعتمد على الحارس: تجيب false على أي طلب لا مستخدمَ
مربوطاً عليه — وهو حال أي مسار جوال بلا app.installation. ومع الوسيط تجيب،
لكن $viewer->can() تبقى ما تسأله: جوابها واحد على الويب والجوال والطابور،
ولا يتوقّف على حارسٍ لا تملكه.
إن رجعت
permissionsوpermissionSourcesفارغتين لشخص يحملها فعلاً — وخاصّةً إن كانتauth()->user()->can()تقول نعم بينما$viewer->can()تقول لا — فاسأل$viewer->hasAppContext()قبل أن تلمس المنح.القائمة مُرشَّحة إلى تطبيقك، فإن لم يحمل الطلبُ سياقَ التطبيق فلا «تطبيقك» هناك، والقائمة فارغة بالبناء مهما ملك الشخص — وهي تبدو تماماً كمن لا يملك شيئاً، وهذا بالضبط ما كان يرسل الجميع يفتّشون في منحٍ سليمة. و
accessLevel() === 'none'عندئذٍ ليست حكماً على الشخص: لم يُسأل عنه شيء.
hasAppContext()تعودfalse⇐ المسار بلا وسيط يبني السياق، والوسيطُ يختلف بين السطحين — راجع الجدول أدناه. وتعودtrueوالقائمة فارغة ⇐ المنح فعلاً، فاطلب من فريق المنصّةphp artisan partners:viewer-permissions <slug> --user=<معرّفه> --tenant=<id>.
أيّ وسيط يبني السياق — الويب شيء والتوكن شيء آخر
module.context:<slug> وسيط ويب حصراً. يقرأ الجهة من Tenant::current()
(أو جهة البوّابة من الجلسة)، ونداء التوكن بلا جلسة فلا جهة — فيردّ 403
no_tenant_context قبل أن يصل مسارك. وضعُه على مجموعة مسارات الجوال يوقف كل
نداءات التطبيق المصغّر. (كانت هذه الفقرة تقول «ضعه على مسار الصفحة وعلى كل ما
تخدمه Livewire منها» بلا أن تقول إنه للويب وحده — وهو ما أوقع فريقاً في ذلك
بالضبط.)
| السطح | ما يُصادِق | الوسيط |
|---|---|---|
| صفحات مودولك على الويب (Livewire) | جلسة المستخدم | module.context:<slug> — على مسار الصفحة وعلى كل ما تخدمه Livewire منها |
| نقاطك التي يناديها تطبيقك المصغّر على الجوال | توكن المستخدم (Sanctum) | app.installation |
| خادمك أنت (من خارج المنصّة) | توكن تثبيت (bearer) | app.installation |
// مجموعة مسارات الجوال — لا module.context هنا
Route::middleware(['api', 'app.installation'])
->prefix('api/__MODULE_SLUG__')
->group(function () {
Route::get('/me', [MeController::class, 'show']);
});
ولا ترويسة تكتبها أنت: المضيف يرسل مع كل نداء من التطبيق المصغّر
X-Module-Slug وX-Okta-Tenant-Id وX-Okta-Role-Id بجانب توكن المستخدم،
وapp.installation يحلّ التثبيت من (الجهة، الـslug) من طرف الخادم، ويعيد
فحص بوّابة البطاقة للدور النشط، ثم يضبط ModuleContext. فتعمل بعدها
GetCurrentViewer كاملةً على الجوال: accountType وpermissions وcan()
وhasAppContext() وactiveRole.
التوكن الذي يصلك توكن مستخدم لا توكن تثبيت. توكن التثبيت اعتماد خادمي على مستوى الجهة ولا يُشحن إلى هاتف أبداً. لذلك الهويّة من المستخدم والصلاحية من التثبيت — تطبيقك المصغّر لا ينال أكثر ممّا مُنح تطبيقُك.
فالقاعدة العملية:
- لا تفرّع أبداً على مفتاح مجموعة أو عنوانها.
group.hrو«مدير الموارد البشرية» مفردات مدرسة واحدة. تطبيقٌ يكتبif ($viewer->groups[0]['title'] === 'مدير الموارد البشرية')يعمل في مدرسة ويصمت في التي بعدها. المجموعة تصلك كي تعرضها بجانب الاسم، لا أكثر. - ما تشتريه المجموعةُ يجيب عنه
can()وحده. المدرسة وضعت في «مدير الموارد البشرية» صلاحية__MODULE_LOWER__.reports.view؟ فاسأل عن الصلاحية لا عن المجموعة — وستعمل الإجابة في المدرسة التي أعطت الصلاحية نفسها لمجموعة اسمها «الوكيل». - نوع الحساب واحد وثابت، فابنِ عليه الشاشة وحدها. الموظّف معلّم أو إداري
— لا الاثنين — ومدير الجهة يعلوهما إن حمل الصفة؛ و
employeeيصل فقط لصفّ قديم لم يقرّر أحد نوعه، فعامله كموظّف بلا نوع لا كنوع ثالث.
$viewer = app(GetCurrentViewer::class)();
// الشاشة: نوع الحساب
if ($viewer->accountType === 'guardian') {
return view('__MODULE_SLUG__::family');
}
// الفعل: الصلاحية — لا المجموعة ولا الدور
$canApprove = $viewer->can('__MODULE_LOWER__.leave_requests.decide');
// لماذا يحملها — حين يهمّ: منحة أساسية أم شيء أضافته المدرسة
$viewer->grantedByGroupOnly('__MODULE_LOWER__.leave_requests.decide');
// العرض فقط: ما تسمّيه المدرسة
$badge = $viewer->groups[0]['title'] ?? null; // «مدير الموارد البشرية»
الباب الذي دخل منه — activeRole
الحقول الثلاثة أعلاه عن الشخص: roles مجموع ما يحمله في الجهة،
وpermissions مجموع ما تحمله تلك الأدوار. وهذا الحقل عن الجلسة: الدور
الواحد الذي ضغطه على صفحة اختيار السياق، وهو ما تكتبه المنصّة تحت اسمه —
«إداري» مرّة و«محاسبة» أخرى، لأن مجموعة الصلاحيات صفُّ دور فهي بابٌ مثل غيره.
| الحقل / الدالة | تجيب عن |
|---|---|
$viewer->activeRole |
['key' => 'group.accounting', 'name' => 'group.accounting', 'title' => 'محاسبة'] — أو null |
$viewer->enteredAs() |
مفتاح الباب، بنفس صيغة roles[] في جمهورك فيقارَن مباشرةً |
$viewer->enteredAsTitle() |
عنوانه كما تعرضه المنصّة — للعرض فقط، لا تقارنه |
$viewer->enteredAsBaseRole() / enteredAsGroup() |
أدخل بدور منصّة أم بمجموعة صنعتها المدرسة |
$viewer->activePermissions |
شريحة permissions التي يحملها ذلك الباب — أو null |
$viewer->activeCan('x.y.view') / activeCanAny([...]) |
هل يحملها هذا الباب |
$viewer = app(GetCurrentViewer::class)();
// الشاشة تتبع الباب: الإدارية العضو في «محاسبة» ترى شاشة الموظّف حين تدخل
// إدارية، وشاشة المالية حين تدخل محاسِبة.
if ($viewer->activeCan('okta_hr.management.view')) {
return view('okta-hr::management');
}
return view('okta-hr::staff');
وثلاث قواعد تحكمه، وكلٌّ منها كُتبت لأن نقيضها يكسر شيئاً:
- يقسّم ولا يأذن. الدخول بدور أساسي لا يسحب ما منحته المدرسة: بوّابة
المنصّة نفسها لا تضيق بالباب، فـ
can()تبقى تجيب على كل ما يحمله الشخص هنا. فاستعملactiveCan()لتقرّر ما يُعرض، وأبقِcan()لتقرّر ما يُفعل — تطبيقٌ يرفض فعلاً لأن الباب لا يحمله يخالف المنصّة في الشخص نفسه واللحظة نفسها، ويرفض على الويب ما يقبله على الهاتف. nullتعني «بلا باب» لا «بلا دور». حين لا يُسجَّل باب على الطلب يعود الحقلانnull، وactiveCan()تسقط تلقائياً إلىcan()— فشاشةٌ مبنيّة عليها لا تفرغ. لا تكتب?? falseحولها؛ ولا تقرأnullرفضاً.- ولا تقبله من العميل أبداً. لا ترسل «الدور المختار» في جسم نداء ولا في ترويسة ولا تخزّنه في المتصفّح: المنصّة تقرؤه من سياق الخادم وحده، ودورٌ يسمّيه المُنادي دورٌ يزوّره.
وعلى الجوال يصلك الباب أيضاً. الهاتف يختار دوراً عند الدخول ويرسله مع كل
نداء (X-Okta-Role-Id)، وapp.installation يتحقّق أنه دورٌ يحمله المُنادي
في هذه الجهة — بوّابة البطاقة تقوم عليه أصلاً — ثم يُسلّمه للـviewer. فهو
ليس ادّعاءً من العميل: دورٌ يسمّيه ولا يحمله لا يمرّ، ودورٌ يحمله لا يفعل إلا
أن يُضيّق ما تعرضه. وcan() لا تتغيّر في الحالتين.
كان هذا القسم يقول إن
activeRoleتعودnullعلى الجوال وإن «الجوال يبقى على مجموع الصلاحيات». كان الدور يُفحص ثم يُهمَل، فصار يصل — والسطح الوحيد الذي لاcan()فيه لم يعد السطح الوحيد بلا باب.
والانتحال («تصفّح نيابةً عن»): activeRole للـمنتحَل عنه — المنصّة
تُعيد بناء السياق كاملاً للشخص المُنتحَل، فالجلسة جلسته والباب بابه. لكن
انتبه: في الانتحال — وفي الدخول إلى بيئة التجربة — لم يضغط أحدٌ بطاقة،
فالمنصّة تختار أول أدواره. فهو دورٌ صحيح يحمله الشخص فعلاً، لكنه ليس «ما
اختاره»؛ ولذلك لا تجعل activeCan() بوّابةَ فعلٍ: الفرق بين البابين يجب أن
يبقى فرق عرض.
أمر جاهز لمساعدك (Claude / Cursor)
انسخه كما هو إلى مساعد الذكاء الاصطناعي داخل مستودع تطبيقك، ليراجع الكود على هذه القاعدة ويصحّح ما يخالفها:
راجع تطبيقنا على قاعدة «نوع الحساب شيء، وما منحته المدرسة شيء آخر» في منصّة
أوكتا، وصحّح ما يخالفها:
أمام الشاشة شخصان قد يبدوان واحداً: إداريّ، وشخصٌ مُنح صلاحيات تحت مسمّى
«مدير الموارد البشرية». الأول نوع حساب — كلمة المنصّة ومعناها واحد في كل
مدرسة. الثاني مجموعة إدارية صنعتها هذه المدرسة وسمّتها كما شاءت، والمدرسة
التالية تسمّي الوظيفة نفسها اسماً آخر أو لا تملكها.
نقرأ الاثنين من GetCurrentViewer:
- $viewer->accountType — مفردات المنصّة الثابتة، واحدة للشخص:
tenant_admin (مدير الجهة، يعلو ما سواه) | administrator | teacher |
student | guardian — و employee يصل فقط لصفّ قديم لم يقرّر أحد نوعه.
الموظّف إمّا معلّم وإمّا إداري، لا الاثنان.
- $viewer->groups — [{key, title}] المجموعات الإدارية التي صنعتها المدرسة،
بعناوينها. للعرض بجانب الاسم فقط.
المطلوب:
1. الشاشة تُبنى على accountType وحده: أي تفريع «بوّابة الأسرة أم سطح
الموظّفين»، وأي إعلان في audiences[]، يقرؤه من هنا. لا من $viewer->roles
الخام ولا من اسم دور.
2. الفعل يُبنى على الصلاحية وحدها: هل يرى هذا الزرّ، هل يستطيع هذا الحفظ →
$viewer->can('__MODULE_LOWER__.<resource>.<action>') (تجيب على الويب والجوال
سواء؛ auth()->user()->can() تجيب false بلا جلسة). لا «هل هو إداري» ولا
«هل هو في مجموعة X». والمساعدات: canAny/canAll، accessLevel()
(full|granted|none)، isTenantAdmin()/isStaff()/isPortal()،
وgrantedByGroupOnly()/grantedByBaseRole()/permissionSources حين يهمّك
لماذا يحملها — منحة الدور الأساسي من rbac_permissions أم شيء أضافته هذه
المدرسة لمجموعة. أعلن للدور الأساسي أقلّ ما يلزمه، واترك ما تريده بيد
المدرسة لمجموعاتها.
3. لا تفريع أبداً على مفتاح مجموعة أو عنوانها. ابحث في الكود عن أي مقارنة
بنصّ مثل 'مدير الموارد البشرية' أو 'group.hr' أو
$viewer->groups[...]['key'|'title'] داخل if/match، واستبدلها بـ can()
على الصلاحية التي تعنيها فعلاً — وأعلن تلك الصلاحية في manifest.json →
rbac_permissions ببادئة feature = __MODULE_LOWER__، وعنوانها في
rbac_labels.
4. لا تعامل employee كنوع ثالث: هو موظّف بلا نوع مسجَّل. اعرض له السطح
العامّ وما تسمح به صلاحياته.
5. لا تفترض أن حامل الصلاحية إداري، ولا أن الإداري يحمل صلاحية: اعرض ما
تسمح به الصلاحيات، وأخفِ ما عداه بدل 403.
6. إن أردت أن يتبع سطحُ الشاشة الدورَ الذي دخل به الشخص (مرّة «إداري» ومرّة
«محاسبة») فاستعمل $viewer->activeCan()/enteredAs()/enteredAsGroup()
للعرض وحده، وأبقِ can() لكل رفض فعل. activeRole تعود null على نداءات
التوكن وactiveCan() تسقط عندها إلى can() — فلا تكتب ?? false حولها ولا
تقرأ null رفضاً.
مثال الشكل الصحيح:
$viewer = app(GetCurrentViewer::class)();
if ($viewer->accountType === 'guardian') return view('__MODULE_SLUG__::family'); // شاشة
$canApprove = auth()->user()->can('__MODULE_LOWER__.leave_requests.decide'); // فعل
$badge = $viewer->groups[0]['title'] ?? null; // عرض فقط
المرجع: دليل المطوّر، فصل «حسابات المنصّة» → قسم «نوع الحساب شيء، وما منحته
المدرسة شيء آخر» — اقرأه بـ read_guide/search_guide قبل التعديل، ثم شغّل
scan_code و validate_manifest على ما غيّرته.
أثر الإعلان على ظهور تطبيقك
الإعلان لا يحكم المسارات وحدها، بل ظهور تطبيقك في مشغّل التطبيقات:
- لم تُعلن جماهير أصلاً؟ بطاقتك تظهر لكل من في الجهة، كما كانت دائماً.
- أعلنت جماهير وطابَق المستخدمُ واحداً؟ تظهر البطاقة، ورابطها هو مسار ذلك الجمهور.
- أعلنت جماهير ولم يطابق أياً منها؟ لا بطاقة إطلاقاً.
الحالة الثالثة مقصودة: بطاقة تفتح صفحة سترفضها البوّابة أسوأ من غياب البطاقة، لأن المستخدم يقرؤها عطلاً في صلاحياته لدى المنصّة لا تطبيقاً لم يكن له. فإن اختفى تطبيقك عن نوع حساب تتوقّع أن يراه، فالعطب في إعلانك لا في تثبيت الجهة.
المحور الثاني: من يملك صلاحيةً من تطبيقك يدخله
نوع الحساب ليس المحور الوحيد، ولا يجوز أن تبني عليه وحده. أسماء الأدوار
التي تكتبها في roles[] هي تخمينك لما تسمّيه المدارس؛ والجهة تخترع أدوارها
(«مشرف الحضور»، «وكيل الشؤون») ولا تسألك. فتطبيقٌ يعلن
roles: ["tenant-admin", "teacher"] يختفي عن موظّفٍ أعطته المدرسة كل
صلاحياته الاثنتي عشرة — لأن المحورين لم يلتقيا قطّ.
لذلك: من يحمل أي صلاحية من rbac_permissions الخاصة بتطبيقك يصل سطحك
العامّ — بطاقة في المشغّل، والبوّابة تسمح، وبطاقة في كتالوج الجوال. منح
الجهةِ الصلاحيةَ هو قولها إن هذا الموظّف يستعمل تطبيقك.
والمحوران يبقيان منفصلين، وهذا هو الشرط:
| المحور | من يقرّره | ثابت؟ | ما يحكمه |
|---|---|---|---|
audiences[] |
أنت، في البيان | نعم | شاشة لكل نوع حساب — أي مسار يفتح كلٌّ منهم |
rbac_permissions |
الجهة، من محرّر الأدوار | لا | ما يُسمح بفعله داخل تطبيقك |
فالصلاحية تفتح menu.route (سطحك العامّ) ولا تفتح شاشة جمهور آخر أبداً:
من لم تُعلنه لـschool-app.admin لا يدخلها ولو حمل كل صلاحياتك. وحالتان
وحدهما لا يُفتح فيهما شيء:
- أن يملك
menu.routeجمهور بوّابة (طالب أو وليّ أمر): صلاحية موظّف ليست طريقاً إلى شاشة الأسرة مهما منحته المدرسة. - أن تكون شاشات موظّفيك مجزَّأة فعلاً — مساران مختلفان أو أكثر —
و
menu.routeأحدها: فالدخول حينها دخولٌ على شاشة بُنيت لغيره.
وأن تشير جماهير الموظّفين كلّها إلى المسار نفسه ليس تجزئة. تطبيقٌ يعلن
جمهوراً واحداً (tenant-admin → app.dashboard) وmenu.route هو ذلك المسار
عينه له شاشة موظّفين واحدة؛ الجمهور هنا يقول من يلقاها افتراضاً لا أنها
ملكه وحده، فيصلها حاملُ الصلاحية. هذه حال أكثر التطبيقات، وهي الحال التي
تعمل فيها القاعدة بلا أن تغيّر شيئاً في بيانك.
ما يعنيه هذا لك عملياً:
- احرس شاشاتك بـ
can()لا بافتراض الدور.auth()->user()->can('__MODULE_LOWER__.orders.view')هو السؤال الصحيح؛ «هل هذا المستخدم tenant-admin؟» سؤالٌ يجيب خطأً في أول مدرسة تسمّي أدوارها بغير ما توقّعت. - أعلن
rbac_permissionsلكل فعل حقيقي في تطبيقك (راجع صلاحيات الأدوار داخل تطبيقك) — فهي الآن بابُ الدخول لا وصفَ الأفعال وحده. - أبقِ لموظّفيك سطحاً واحداً يستقبل من دخل بالصلاحية، ثم فرّع منه
بـ
can()— إمّاmenu.routeلا يملكه جمهور، وإمّا شاشة موظّفين واحدة يشير إليها جمهورك كلّه. تطبيقٌ يشقّ موظّفيه على مسارات مختلفة يغلق بابه على كل دور لم تتوقّعه.
الوضع الحالي: البوّابة تسجّل ولا ترفض بعد
PARTNER_AUDIENCE_GUARD_MODE قيمته الافتراضية log اليوم: البوّابة
تسجّل المخالفة وتخدم الصفحة. وهذا يعني أن إعلاناً خاطئاً — مفتاح بوّابة
داخل roles[]، أو دور مخترَع بلا custom — يبدو عاملاً الآن، ثم يتوقّف
تماماً في اللحظة التي تُقلَب فيها القيمة إلى enforce.
فلا تختبر إعلانك على سلوك اليوم. اختبره تحت الفرض:
config(['partners.audience_guard.mode' => 'enforce']);
والقاعدة عندها الرفض بالافتراض داخل التطبيق المقسَّم: ما إن تُعلن
audiences حتى يصير كل مسار لا يغطّيه أي جمهور مرفوضاً. فصفحة نسيت
إعلانها لا تبقى مفتوحة — تُغلق. أحصِ مساراتك قبل أن تُعلن أول جمهور.
ملاحظة: إخفاء البطاقة في المشغّل يعمل الآن بلا انتظار قلب الوضع، لأنه ترشيح عرض لا حراسة. فقد ترى تطبيقك مخفياً عن مستخدم وما يزال يستطيع فتح مساره بكتابته — هذا هو الفارق بين
logوenforceبعينه.