From 29423f0ecc13969e900650892139f3183eb8a883 Mon Sep 17 00:00:00 2001 From: sultanaalyami <12185541+sultanaalyami@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:59:03 +0300 Subject: [PATCH] feat: publish ADG adjudication domain and social sharing Move the canonical portal to adg.sbay.sa, preserve legacy redirects, add private social usernames and ready-made invitation sharing, and refresh the production import actions.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/import-msa-adjudication.yml | 6 +- .../msa-adjudication-portal-security.yml | 6 +- README.md | 10 +- .../msa-adjudication-workbench/DEPLOYMENT.md | 8 +- tools/msa-adjudication-workbench/PRIVACY.md | 14 +- tools/msa-adjudication-workbench/README.md | 8 +- .../package-lock.json | 4 +- tools/msa-adjudication-workbench/package.json | 2 +- .../public/admin/admin.css | 18 +++ .../public/admin/admin.js | 70 ++++++++- .../msa-adjudication-workbench/public/app.js | 134 ++++++++++++++-- .../public/index.html | 118 ++++++++++++-- .../public/styles.css | 134 ++++++++++++++++ tools/msa-adjudication-workbench/src/index.js | 144 ++++++++++++++---- .../tests/worker.test.mjs | 75 +++++++-- .../wrangler.example.jsonc | 15 +- 16 files changed, 674 insertions(+), 92 deletions(-) diff --git a/.github/workflows/import-msa-adjudication.yml b/.github/workflows/import-msa-adjudication.yml index 355b8fa..91d5769 100644 --- a/.github/workflows/import-msa-adjudication.yml +++ b/.github/workflows/import-msa-adjudication.yml @@ -20,10 +20,10 @@ jobs: environment: msa-adjudication-production steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Sign in to Azure with OIDC - uses: azure/login@v2 + uses: azure/login@v3 with: client-id: ${{ vars.ADG_AZURE_CLIENT_ID }} tenant-id: ${{ vars.ADG_AZURE_TENANT_ID }} @@ -110,7 +110,7 @@ jobs: --base main ` --head $branch ` --title "evidence: import anonymized MSA adjudication" ` - --body "Imports signed, PII-free submissions from ads.sbay.sa. Human identity remains in private Azure storage. Every artifact still requires repository review before use." + --body "Imports signed, PII-free submissions from adg.sbay.sa. Human identity remains in private Azure storage. Every artifact still requires repository review before use." "url=$url" | Out-File $env:GITHUB_OUTPUT -Append - name: Archive accepted queue items diff --git a/.github/workflows/msa-adjudication-portal-security.yml b/.github/workflows/msa-adjudication-portal-security.yml index c4ff1c4..f0c16a0 100644 --- a/.github/workflows/msa-adjudication-portal-security.yml +++ b/.github/workflows/msa-adjudication-portal-security.yml @@ -17,10 +17,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 22 cache: npm @@ -88,7 +88,7 @@ jobs: Set-Content security/reports/msa-adjudication-portal.json - name: Upload security report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: msa-adjudication-portal-security path: security/reports/msa-adjudication-portal.json diff --git a/README.md b/README.md index 7180cd1..03fea38 100644 --- a/README.md +++ b/README.md @@ -41,14 +41,16 @@ This public repository is the official community-facing language repository for: Arabic teachers and linguists can evaluate the parser without GitHub or command-line knowledge at: -**https://ads.sbay.sa** +**https://adg.sbay.sa** The Arabic-first portal explains every criterion, hides parser predictions, keeps participant identity encrypted outside GitHub, and is designed to import only signed, pseudonymous linguistic evidence through a review pull request. -Passkey accounts and encrypted draft resumption are live; central submission -remains disabled until the import workflow is merged and revalidated. Its -source and security model are under `tools\msa-adjudication-workbench`. +Passkey accounts, encrypted draft resumption, optional private social +usernames, and central submission are live. Ready-made WhatsApp and X/Twitter +buttons help invite other experts. Every submitted artifact still enters the +repository through an automated validation and review pull request. Its source +and security model are under `tools\msa-adjudication-workbench`. ## Start Here diff --git a/tools/msa-adjudication-workbench/DEPLOYMENT.md b/tools/msa-adjudication-workbench/DEPLOYMENT.md index b862eaf..569be71 100644 --- a/tools/msa-adjudication-workbench/DEPLOYMENT.md +++ b/tools/msa-adjudication-workbench/DEPLOYMENT.md @@ -1,6 +1,8 @@ # ADS deployment -Target: `https://ads.sbay.sa` +Canonical target: `https://adg.sbay.sa` + +Legacy target: `https://ads.sbay.sa` (HTTP 308 redirect) ## Azure resources @@ -59,7 +61,9 @@ Entra ordinary variables: The Entra application must include this Web redirect URI: -`https://ads.sbay.sa/signin-microsoft` +`https://adg.sbay.sa/signin-microsoft` + +The Turnstile widget hostname allowlist must contain `adg.sbay.sa`. It requires delegated `User.Read` and the existing application permission `RoleManagement.Read.Directory` with tenant-admin consent. The administrative diff --git a/tools/msa-adjudication-workbench/PRIVACY.md b/tools/msa-adjudication-workbench/PRIVACY.md index c3ad23f..34d6342 100644 --- a/tools/msa-adjudication-workbench/PRIVACY.md +++ b/tools/msa-adjudication-workbench/PRIVACY.md @@ -2,7 +2,9 @@ ## البيانات التي نجمعها -- الاسم والبريد ورقم الهاتف؛ +- الاسم والبريد؛ +- أسماء المستخدم الاختيارية في حسابات التواصل، ومنها اسم مستخدم + واتساب بدل رقم الهاتف؛ - سنوات الخبرة والتخصص والجهة الاختيارية؛ - الموافقات وتعهدات الاستقلال والتعمية؛ - القرارات اللغوية المسجلة في العينة. @@ -12,7 +14,7 @@ تُشفّر بيانات الهوية أولًا بملف EntityCrypt Matryoshka العشوائي `AES-256-GCM` ومفتاح محفوظ في Azure Key Vault، ثم تحفظ في حاوية Azure خاصة. تُحفظ النتيجة اللغوية في حاوية منفصلة بمعرف عشوائي وتوقيع HMAC. لا يصل إلى -GitHub الاسم أو البريد أو الهاتف أو الجهة. +GitHub الاسم أو البريد أو حسابات التواصل أو الجهة. عند إنشاء حساب الاستكمال، يسجل المتصفح مفتاح مرور عام فقط، وتبقى مادته الخاصة داخل الجهاز أو مدير مفاتيح المرور. تحفظ قاعدة D1 المفتاح العام والعداد @@ -26,9 +28,11 @@ GitHub الاسم أو البريد أو الهاتف أو الجهة. ## الاحتفاظ والوصول -يجب على مشغل المنصة تحديد مدة احتفاظ معلنة قبل دعوة المحكّمين، وقصر الوصول -إلى سجلات الهوية على منسق التقييم المخول. حذف الهوية لا يقتضي حذف النتيجة -المجهّلة إذا تعذر عمليًا ربطها بالشخص. +تُحفظ المسودات غير النشطة مدة 90 يومًا، وتُحفظ بيانات الهوية والتواصل مدة +12 شهرًا من إغلاق جولة التحكيم ثم تحذف، ما لم يطلب صاحبها الحذف قبل ذلك أو +توجد موافقة صريحة على جولة لاحقة. يمكن الاحتفاظ بالنتيجة المجهّلة بوصفها +دليلًا بحثيًا بعد حذف الهوية إذا تعذر عمليًا ربطها بالشخص. يقتصر الوصول إلى +سجلات الهوية على منسق التقييم المخول. لوحة المتابعة منفصلة على `/admin/`، ولا تفك بيانات التواصل لعرضها إلا بعد دخول Microsoft Entra والتحقق الخادمي من دور Global Administrator. لا تمنح diff --git a/tools/msa-adjudication-workbench/README.md b/tools/msa-adjudication-workbench/README.md index 656b7e7..f5176bd 100644 --- a/tools/msa-adjudication-workbench/README.md +++ b/tools/msa-adjudication-workbench/README.md @@ -1,9 +1,13 @@ # Arabic Adjudication Studio (ADS) -`ads.sbay.sa` is the Arabic-first human adjudication portal for ADG-Lang. It is +`adg.sbay.sa` is the Arabic-first human adjudication portal for ADG-Lang. It is designed for experienced Arabic teachers who should not need GitHub, JSON, or command-line knowledge. +The previous `ads.sbay.sa` address redirects to the canonical domain so old +invitations remain usable. The public page includes ready-made WhatsApp and +X/Twitter invitation actions. + ## User workflow 1. Read the Arabic criteria and parser summary. @@ -26,6 +30,8 @@ organization member has authoritative Global Administrator proof. - Parser predictions are never displayed. - Packet and submission roots are recomputed in the browser and in .NET. - Azure stores identity separately from linguistic evidence. +- Optional social usernames, including the WhatsApp username rather than a + phone number, remain encrypted with the private identity record. - GitHub receives only a pseudonymous, HMAC-signed envelope. - Cloudflare Turnstile and same-origin checks protect the public endpoint. - D1 stores opaque account identifiers, Passkey public keys and counters, diff --git a/tools/msa-adjudication-workbench/package-lock.json b/tools/msa-adjudication-workbench/package-lock.json index da4fcbb..feed9e1 100644 --- a/tools/msa-adjudication-workbench/package-lock.json +++ b/tools/msa-adjudication-workbench/package-lock.json @@ -1,12 +1,12 @@ { "name": "adg-msa-adjudication-workbench", - "version": "14.1.0", + "version": "14.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "adg-msa-adjudication-workbench", - "version": "14.1.0", + "version": "14.2.0", "dependencies": { "@simplewebauthn/server": "^13.3.2" }, diff --git a/tools/msa-adjudication-workbench/package.json b/tools/msa-adjudication-workbench/package.json index 9bf55aa..29eb90f 100644 --- a/tools/msa-adjudication-workbench/package.json +++ b/tools/msa-adjudication-workbench/package.json @@ -1,6 +1,6 @@ { "name": "adg-msa-adjudication-workbench", - "version": "14.1.0", + "version": "14.2.0", "private": true, "type": "module", "scripts": { diff --git a/tools/msa-adjudication-workbench/public/admin/admin.css b/tools/msa-adjudication-workbench/public/admin/admin.css index acc69b8..f923c2d 100644 --- a/tools/msa-adjudication-workbench/public/admin/admin.css +++ b/tools/msa-adjudication-workbench/public/admin/admin.css @@ -206,6 +206,24 @@ font-size: .83rem; } +.social-handles { + display: flex; + flex-wrap: wrap; + gap: .25rem .4rem; + margin-top: .4rem; +} + +.participant-name .social-handles a, +.participant-name .social-handles span { + display: inline-flex; + padding: .15rem .4rem; + border-radius: .45rem; + color: var(--green-800); + background: var(--green-100); + font-size: .72rem; + text-decoration: none; +} + .participant-name span, .assignment small, .muted { diff --git a/tools/msa-adjudication-workbench/public/admin/admin.js b/tools/msa-adjudication-workbench/public/admin/admin.js index 1ff6673..785f845 100644 --- a/tools/msa-adjudication-workbench/public/admin/admin.js +++ b/tools/msa-adjudication-workbench/public/admin/admin.js @@ -107,8 +107,8 @@ function renderParticipants() { const haystack = [ participant.fullName, participant.email, - participant.phone, - participant.affiliation + participant.affiliation, + ...Object.values(participant.socialAccounts || {}) ].filter(Boolean).join(" ").toLocaleLowerCase("ar"); return matchesStatus && (!query || haystack.includes(query)); }); @@ -142,16 +142,74 @@ function participantCell(participant) { const email = document.createElement("a"); email.href = `mailto:${participant.email}`; email.textContent = participant.email; - const phone = document.createElement("a"); - phone.href = `tel:${participant.phone.replace(/[ ()-]/g, "")}`; - phone.textContent = participant.phone; const affiliation = document.createElement("span"); affiliation.textContent = participant.affiliation || "بلا جهة مسجلة"; - wrapper.append(name, email, phone, affiliation); + wrapper.append(name, email, affiliation); + const social = socialAccountsElement(participant.socialAccounts); + if (social) wrapper.append(social); cell.append(wrapper); return cell; } +function socialAccountsElement(accounts = {}) { + const labels = { + whatsapp: "واتساب", + x: "X", + tiktok: "TikTok", + instagram: "Instagram", + threads: "Threads", + telegram: "Telegram", + snapchat: "Snapchat", + facebook: "Facebook", + linkedin: "LinkedIn", + youtube: "YouTube", + bluesky: "Bluesky" + }; + const links = { + x: handle => `https://x.com/${encodeURIComponent(handle)}`, + tiktok: handle => + `https://www.tiktok.com/@${encodeURIComponent(handle)}`, + instagram: handle => + `https://www.instagram.com/${encodeURIComponent(handle)}`, + threads: handle => + `https://www.threads.net/@${encodeURIComponent(handle)}`, + telegram: handle => `https://t.me/${encodeURIComponent(handle)}`, + snapchat: handle => + `https://www.snapchat.com/add/${encodeURIComponent(handle)}`, + facebook: handle => + `https://www.facebook.com/${encodeURIComponent(handle)}`, + linkedin: handle => + `https://www.linkedin.com/in/${encodeURIComponent(handle)}`, + youtube: handle => + `https://www.youtube.com/@${encodeURIComponent(handle)}`, + bluesky: handle => + `https://bsky.app/profile/${encodeURIComponent(handle)}` + }; + const container = document.createElement("div"); + container.className = "social-handles"; + for (const [key, label] of Object.entries(labels)) { + const handle = accounts[key]; + if (!handle) continue; + const element = links[key] + ? document.createElement("a") + : document.createElement("span"); + if (links[key]) { + element.href = links[key](handle); + element.target = "_blank"; + element.rel = "noopener noreferrer"; + } + element.textContent = `${label}: @${handle}`; + container.append(element); + } + if (accounts.otherPlatform && accounts.otherUsername) { + const other = document.createElement("span"); + other.textContent = + `${accounts.otherPlatform}: @${accounts.otherUsername}`; + container.append(other); + } + return container.childElementCount === 0 ? null : container; +} + function experienceCell(participant) { const label = specializationLabel(participant.specialization); return textCell(`${label} · ${participant.experienceYears} سنة`); diff --git a/tools/msa-adjudication-workbench/public/app.js b/tools/msa-adjudication-workbench/public/app.js index 388f04a..01e4559 100644 --- a/tools/msa-adjudication-workbench/public/app.js +++ b/tools/msa-adjudication-workbench/public/app.js @@ -76,6 +76,25 @@ const IRAB = [ ["mafool-maah", "مفعول معه"] ]; +const PUBLIC_PORTAL_URL = "https://adg.sbay.sa/"; +const INVITATION_TEXT = + "دعوة لمعلمي اللغة العربية وخبرائها للمشاركة في التحكيم اللغوي " + + "المستقل لمحلل ADG-Lang. لا تحتاج إلى خبرة تقنية، ويمكن حفظ " + + "العمل والعودة إليه لاحقًا."; +const SOCIAL_FIELDS = [ + ["whatsapp", "social-whatsapp"], + ["x", "social-x"], + ["tiktok", "social-tiktok"], + ["instagram", "social-instagram"], + ["threads", "social-threads"], + ["telegram", "social-telegram"], + ["snapchat", "social-snapchat"], + ["facebook", "social-facebook"], + ["linkedin", "social-linkedin"], + ["youtube", "social-youtube"], + ["bluesky", "social-bluesky"] +]; + const FIELD_HELP = { upos: "الصنف العام للكلمة، مثل اسم أو فعل أو ضمير.", head: "رقم الكلمة التي تتعلق بها؛ استخدم 0 لجذر الجملة.", @@ -134,6 +153,10 @@ const saveDraftButton = document.querySelector("#save-draft"); const draftStatus = document.querySelector("#draft-status"); const savedDrafts = document.querySelector("#saved-drafts"); const draftList = document.querySelector("#draft-list"); +const shareWhatsapp = document.querySelector("#share-whatsapp"); +const shareX = document.querySelector("#share-x"); +const copyInvitationButton = document.querySelector("#copy-invitation"); +const shareStatus = document.querySelector("#share-status"); document.querySelectorAll('input[name="role"]').forEach(control => { control.addEventListener("change", syncRoleControls); @@ -149,6 +172,7 @@ registerPasskeyButton.addEventListener("click", registerPasskey); loginPasskeyButton.addEventListener("click", loginWithPasskey); logoutAccountButton.addEventListener("click", logoutAccount); saveDraftButton.addEventListener("click", saveDraft); +copyInvitationButton.addEventListener("click", copyInvitation); workspace.addEventListener("input", () => { updateCompletion(); scheduleAutosave(); @@ -169,8 +193,30 @@ document.addEventListener("visibilitychange", () => { } }); +configureSharing(); initialize(); +function configureSharing() { + const message = `${INVITATION_TEXT}\n\n${PUBLIC_PORTAL_URL}`; + shareWhatsapp.href = + `https://wa.me/?text=${encodeURIComponent(message)}`; + shareX.href = + "https://twitter.com/intent/tweet?text=" + + encodeURIComponent(INVITATION_TEXT) + + `&url=${encodeURIComponent(PUBLIC_PORTAL_URL)}`; +} + +async function copyInvitation() { + const message = `${INVITATION_TEXT}\n\n${PUBLIC_PORTAL_URL}`; + try { + await navigator.clipboard.writeText(message); + shareStatus.textContent = "نُسخت الدعوة."; + } catch { + shareStatus.textContent = + "تعذر النسخ الآلي؛ استخدم خيار واتساب أو X."; + } +} + async function initialize() { applyRoleFromQuery(); syncRoleControls(); @@ -248,6 +294,11 @@ function showStep(step) { previousButton.hidden = step === 1; nextButton.hidden = step === 5; nextButton.textContent = step === 4 ? "مراجعة النتيجة" : "التالي"; + if (step === 5) { + void ensureTurnstileWidget().catch(error => { + showStatus(error.message, true); + }); + } document.querySelector("#adjudication").scrollIntoView({ behavior: "smooth", block: "start" @@ -257,21 +308,17 @@ function showStep(step) { function validateProfile() { const fullName = value("full-name"); const email = value("email"); - const phone = value("phone"); const years = Number(value("experience-years")); requireText(fullName, "الاسم الكامل"); requireText(email, "البريد الإلكتروني"); - requireText(phone, "رقم الهاتف"); requireText(value("specialization"), "مجال التخصص"); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { throw new Error("أدخل بريدًا إلكترونيًا صالحًا."); } - if (!/^\+?[0-9 ()-]{7,24}$/.test(phone)) { - throw new Error("أدخل رقم هاتف صالحًا مع رمز الدولة."); - } if (!Number.isInteger(years) || years < 0 || years > 80) { throw new Error("سنوات الخبرة يجب أن تكون بين صفر و80."); } + validateSocialAccounts(socialAccounts()); if (!checked("privacy-consent")) { throw new Error("الموافقة على حفظ بيانات التواصل مطلوبة للإرسال."); } @@ -281,13 +328,62 @@ function accountProfile() { return { fullName: value("full-name"), email: value("email"), - phone: value("phone"), experienceYears: Number(value("experience-years")), specialization: value("specialization"), - affiliation: nullable(value("affiliation")) + affiliation: nullable(value("affiliation")), + socialAccounts: socialAccounts() }; } +function socialAccounts() { + const accounts = {}; + for (const [key, id] of SOCIAL_FIELDS) { + const handle = normalizeSocialHandle(value(id)); + if (handle) accounts[key] = handle; + } + const otherPlatform = value("social-other-platform"); + const otherUsername = normalizeSocialHandle( + value("social-other-username") + ); + if (otherPlatform) accounts.otherPlatform = otherPlatform; + if (otherUsername) accounts.otherUsername = otherUsername; + return accounts; +} + +function normalizeSocialHandle(handle) { + return handle.trim().replace(/^@+/, ""); +} + +function validateSocialAccounts(accounts) { + if (accounts.whatsapp + && !/^[a-z][a-z0-9._]{2,34}$/.test(accounts.whatsapp)) { + throw new Error( + "اسم مستخدم واتساب يجب أن يبدأ بحرف لاتيني صغير، وأن يتكون " + + "من 3 إلى 35 حرفًا أو رقمًا أو نقطة أو شرطة سفلية." + ); + } + for (const [key, handle] of Object.entries(accounts)) { + if (key === "otherPlatform") continue; + if (!/^[^\s/@?#]{1,80}$/u.test(handle)) { + throw new Error( + "اكتب أسماء المستخدم من دون @ أو مسافات أو روابط كاملة." + ); + } + } + const hasOtherPlatform = Boolean(accounts.otherPlatform); + const hasOtherUsername = Boolean(accounts.otherUsername); + if (hasOtherPlatform !== hasOtherUsername) { + throw new Error( + "أكمل اسم المنصة الأخرى واسم المستخدم فيها معًا." + ); + } + if (hasOtherPlatform + && (accounts.otherPlatform.length < 2 + || accounts.otherPlatform.length > 40)) { + throw new Error("اسم المنصة الأخرى غير صالح."); + } +} + function accountConsent() { return { identityStorage: checked("privacy-consent"), @@ -321,13 +417,20 @@ function fillProfile(profile, consent) { if (!profile) return; document.querySelector("#full-name").value = profile.fullName ?? ""; document.querySelector("#email").value = profile.email ?? ""; - document.querySelector("#phone").value = profile.phone ?? ""; document.querySelector("#experience-years").value = profile.experienceYears ?? ""; document.querySelector("#specialization").value = profile.specialization ?? ""; document.querySelector("#affiliation").value = profile.affiliation ?? ""; + const social = profile.socialAccounts ?? {}; + for (const [key, id] of SOCIAL_FIELDS) { + document.querySelector(`#${id}`).value = social[key] ?? ""; + } + document.querySelector("#social-other-platform").value = + social.otherPlatform ?? ""; + document.querySelector("#social-other-username").value = + social.otherUsername ?? ""; document.querySelector("#privacy-consent").checked = consent?.identityStorage === true; document.querySelector("#contact-consent").checked = @@ -1268,8 +1371,9 @@ function renderReview() {
- راجع التعهدات أدناه. سيُحفظ اسمك وبريدك وهاتفك في Azure منفصلًا، - بينما تُرسل القرارات اللغوية إلى قناة الاستيراد الخاصة بالمستودع. + راجع التعهدات أدناه. سيُحفظ اسمك وبريدك وحسابات التواصل التي + أضفتها في Azure منفصلة، بينما تُرسل القرارات اللغوية إلى قناة + الاستيراد الخاصة بالمستودع.
`; } @@ -1333,7 +1437,7 @@ async function submitEvaluation() { artifactType: artifact.kind, artifactSha256, artifact, - clientVersion: "ads-v14.1", + clientVersion: "adg-v14.2", turnstileToken }; const body = JSON.stringify(payload); @@ -1369,6 +1473,14 @@ async function configureTurnstile() { await loadScript( "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" ); +} + +async function ensureTurnstileWidget() { + if (!state.config.turnstileSiteKey + || state.turnstileWidgetId !== null) { + return; + } + if (!window.turnstile) await configureTurnstile(); state.turnstileWidgetId = turnstile.render("#turnstile-slot", { sitekey: state.config.turnstileSiteKey, language: "ar", diff --git a/tools/msa-adjudication-workbench/public/index.html b/tools/msa-adjudication-workbench/public/index.html index 5175cea..10fa2cb 100644 --- a/tools/msa-adjudication-workbench/public/index.html +++ b/tools/msa-adjudication-workbench/public/index.html @@ -46,6 +46,23 @@لا تُعرض نتائج البارسر، ولا تستخدم PADT أو PUD كعينة نهائية، - ولا يُنشر اسم المحكّم أو بريده أو هاتفه في المستودع. + ولا يُنشر اسم المحكّم أو بريده أو حساباته التواصلية في + المستودع.
@@ -242,12 +260,6 @@