+ {policy.present ? admin('invitations.policy_stored') : admin('invitations.policy_default')}
+
+ {policy.canEdit && snapshot.scopeKind === 'tenant' ? (
+ = {
+ ...base,
+ role: { ...base.role, grants: [{ scope: 'Tenant', key: 'tenancy.setting.write' }] },
+ };
+ const { document } = documentFor(view, {
+ ownProfile: { displayName: null, preferredLocale: locale, version: '1' },
+ });
+ const text =
+ locale === 'en'
+ ? 'Manage institution appearance and invitation lifetime'
+ : 'Kurum görünümünü ve davet süresini yönetme';
+ expect(document.body.textContent).toContain(text);
+ expect(document.body.textContent).not.toContain('tenancy.setting.write');
+ },
+ );
it('uses site fallback for missing/disabled preference and never uses the viewed member preference', () => {
const view = {
kind: 'member',
diff --git a/frontend/apps/web/src/components/auth/history.ts b/frontend/apps/web/src/components/auth/history.ts
index 6517ff0a3..6bf4769fd 100644
--- a/frontend/apps/web/src/components/auth/history.ts
+++ b/frontend/apps/web/src/components/auth/history.ts
@@ -3,4 +3,4 @@
// Native documents always carry a fixed GET recovery target: reloading a POST
// result could replay a mutation. The inline guard independently closes the DOM
// attribute to the same finite screens; invalid attributes fall back to sign-in.
-export const BFF_HISTORY_SOURCE = `(()=>{let leaving=false;addEventListener('pagehide',()=>{leaving=true;document.documentElement.style.visibility='hidden'});addEventListener('pageshow',e=>{if(e.persisted||leaving){document.documentElement.style.visibility='hidden';const r=document.documentElement.getAttribute('data-bff-history-recovery');if(r===null){location.reload()}else{const allowed=['/','/studio','/studio/members','/studio/roles','/portal','/portal/profile','/api/auth/entry','/api/auth/signin'];location.replace(allowed.includes(r)||/^\\/studio\\/(?:members|roles)\\/[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(r)?r:'/api/auth/signin')}}})})();`;
+export const BFF_HISTORY_SOURCE = `(()=>{let leaving=false;addEventListener('pagehide',()=>{leaving=true;document.documentElement.style.visibility='hidden'});addEventListener('pageshow',e=>{if(e.persisted||leaving){document.documentElement.style.visibility='hidden';const r=document.documentElement.getAttribute('data-bff-history-recovery');if(r===null){location.reload()}else{const allowed=['/','/studio','/studio/members','/studio/roles','/studio/invitations','/studio/invitation-policy','/portal','/portal/profile','/api/auth/entry','/api/auth/signin'];location.replace(allowed.includes(r)||/^\\/studio\\/(?:members|roles|invitations)\\/[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(r)?r:'/api/auth/signin')}}})})();`;
diff --git a/frontend/apps/web/src/components/auth/shell.tsx b/frontend/apps/web/src/components/auth/shell.tsx
index 5fea15894..a1cef01ab 100644
--- a/frontend/apps/web/src/components/auth/shell.tsx
+++ b/frontend/apps/web/src/components/auth/shell.tsx
@@ -12,6 +12,7 @@ import { optionalPreparedPage } from '@/server/auth/page';
import type { NativePageSnapshot, PageView } from '@/server/auth/page-contract';
import { MutationFields, ProfileFields, ProfileValues } from './admin-forms';
+import { InvitationDetails, InvitationDirectory, InvitationPolicy } from './admin-invitations';
import { MemberDetails, MemberDirectory } from './admin-members';
import { RoleDetails, RoleDirectory } from './admin-roles';
import { SessionControls } from './session';
@@ -26,6 +27,9 @@ function title(kind: PageKind, admin: AdminCopy, profile: ProfileCopy, copy: Aut
roles: admin('roles.title'),
role: admin('roles.detail_title'),
profile: profile('title'),
+ invitations: admin('invitations.title'),
+ invitation: admin('invitations.detail_title'),
+ invitation_policy: admin('invitations.policy_title'),
};
return titles[kind];
}
@@ -44,6 +48,12 @@ function PageContents({
readonly profile: ProfileCopy;
}) {
switch (view.kind) {
+ case 'invitations':
+ return ;
+ case 'invitation':
+ return ;
+ case 'invitation_policy':
+ return ;
case 'members':
return ;
case 'member':
@@ -165,6 +175,12 @@ export function PreparedAccountPage({
{admin('navigation.members')}
)}
{capabilities?.canReadRoles && {admin('navigation.roles')}}
+ {capabilities?.canManageMemberships && (
+ {admin('navigation.invitations')}
+ )}
+ {snapshot.scopeKind === 'tenant' && (
+ {admin('navigation.invitation_policy')}
+ )}
{admin('navigation.profile')}
{admin('navigation.contexts')}
{copy('shell.catalog')}
@@ -184,7 +200,11 @@ export function PreparedAccountPage({
{snapshot.outcome.reason === 'invalid_cursor'
diff --git a/frontend/apps/web/src/i18n/messages/en/admin.json b/frontend/apps/web/src/i18n/messages/en/admin.json
index ab3c5ccb6..7242a4fad 100644
--- a/frontend/apps/web/src/i18n/messages/en/admin.json
+++ b/frontend/apps/web/src/i18n/messages/en/admin.json
@@ -4,7 +4,9 @@
"members": "Members",
"roles": "Roles",
"profile": "My profile",
- "contexts": "Change account access"
+ "contexts": "Change account access",
+ "invitations": "Invitations",
+ "invitation_policy": "Invitation lifetime"
},
"scope": {
"tenant": "Institution-wide access",
@@ -39,7 +41,11 @@
"revoke_hint": "Revoking ends this membership permanently. It cannot be reactivated from this page.",
"suspend_hint": "Suspension pauses access while preserving the membership and its assignments."
},
- "status": { "active": "Active", "suspended": "Suspended", "revoked": "Revoked" },
+ "status": {
+ "active": "Active",
+ "suspended": "Suspended",
+ "revoked": "Revoked"
+ },
"roles": {
"title": "Roles",
"detail_title": "Role details",
@@ -74,7 +80,7 @@
"organization_profiles": "Edit branch membership profiles",
"organizations": "Manage organizations",
"locales": "Manage institution languages",
- "settings": "Manage institution appearance",
+ "settings": "Manage institution appearance and invitation lifetime",
"unavailable": "Permission label unavailable"
},
"failure": {
@@ -86,5 +92,54 @@
"busy": "Too many requests are in progress. Wait before trying again.",
"retry": "Check this view again",
"retry_after": "Seconds to wait before trying again"
+ },
+ "invitations": {
+ "title": "Invitations",
+ "detail_title": "Invitation details",
+ "create": "Invite a person",
+ "create_hint": "Send an invitation to this institution or branch with one offered role. Email is sent automatically.",
+ "recipient": "Recipient email",
+ "email_hint": "Use the email address the person will verify on their account.",
+ "offered_role": "Offered role",
+ "choose_role": "Choose an offered role",
+ "send": "Create and email invitation",
+ "no_roles": "No roles are currently available to offer with your access.",
+ "list": "Invitation history",
+ "empty": "No invitations are available in this view.",
+ "next": "Next invitations",
+ "back": "Back to invitations",
+ "scope": "Membership scope",
+ "state": "Invitation status",
+ "expires": "Expires at (UTC)",
+ "delivery": "Email submission status",
+ "pending": "Pending acceptance",
+ "accepted": "Accepted",
+ "revoked": "Revoked",
+ "expired": "Expired",
+ "queued": "Queued",
+ "submission_accepted": "Submission accepted by the mail server",
+ "failed": "Submission failed",
+ "uncertain": "Submission uncertain",
+ "unavailable": "Submission status unavailable",
+ "delivery_hint": "Mail server acceptance does not confirm inbox delivery or that the email was read.",
+ "uncertain_hint": "The email may still arrive. Unfinished work continues to use delivery capacity; an unknown result is not a confirmed failure.",
+ "already_member": "The person already had an active membership. Their existing roles and profile were preserved; the offered role was not added.",
+ "created_member": "A new membership was created with the offered role.",
+ "role_unavailable": "Offered role label is no longer available with your access.",
+ "role_changed": "The offered role is no longer available with its original version. Revoke this offer and create a new invitation to choose the current role.",
+ "other_scope": "This invitation is outside your current access context. Change account access to manage it.",
+ "reissue": "Replace and email invitation",
+ "reissue_hint": "This replaces the current code and expiry. An older email can still arrive, but its code will no longer work. Unfinished earlier email work retains its delivery slot.",
+ "revoke": "Revoke invitation",
+ "revoke_hint": "This ends the offer. An email already in progress may still arrive, but it cannot grant membership.",
+ "policy_title": "Invitation lifetime",
+ "policy_hint": "Choose the lifetime for future invitations and explicit replacements. Existing invitation expiry times stay unchanged.",
+ "policy_stored": "This institution has a saved lifetime policy.",
+ "policy_default": "No policy has been saved. The default lifetime is 14 days.",
+ "policy_days": "Lifetime in days",
+ "policy_range": "Enter a whole number from 1 to 30.",
+ "policy_save": "Save invitation lifetime",
+ "policy_read_only": "You can view this policy. Editing requires institution-wide settings permission.",
+ "required": "Required"
}
}
diff --git a/frontend/apps/web/src/i18n/messages/en/invitation.json b/frontend/apps/web/src/i18n/messages/en/invitation.json
new file mode 100644
index 000000000..aac090315
--- /dev/null
+++ b/frontend/apps/web/src/i18n/messages/en/invitation.json
@@ -0,0 +1,60 @@
+{
+ "common": {
+ "title": "Invitation",
+ "skip": "Skip to content",
+ "cancel": "Cancel invitation",
+ "home": "Return to institution site",
+ "signout": "Sign out to use another account",
+ "required": "Required"
+ },
+ "landing": {
+ "title": "Open your invitation",
+ "description": "Continue with the invitation from your email, or paste the complete code from that email below.",
+ "code_label": "Invitation code",
+ "continue": "Continue",
+ "no_script": "JavaScript is off. Open the clean /invitations address and paste the separate code from the email. A fragment in the address cannot be removed automatically without JavaScript.",
+ "invalid": "This invitation code is not valid. Paste the complete code from the original email."
+ },
+ "confirm": {
+ "signin": "Sign in to the account that received the invitation. You can create an account on the sign-in page.",
+ "signin_action": "Sign in or create an account",
+ "bootstrap": "Create your LearnStack identity to continue. This does not yet join the institution.",
+ "bootstrap_action": "Create my LearnStack identity",
+ "inspect": "Check this invitation using your account’s current verified email. If you already accepted it, check the original result.",
+ "inspect_action": "Check invitation",
+ "forbidden": "This account cannot currently confirm the invitation. Verify your email, or sign out and use the account that received the invitation. If you accepted it before, check the original result.",
+ "verify_action": "Verify my email",
+ "verification_hint": "After completing email verification, return here and check the invitation again. Verification alone does not accept it.",
+ "ready": "Confirm the verified account and the offered membership before accepting.",
+ "email": "Your verified email",
+ "institution": "Institution",
+ "branch": "Branch",
+ "tenant_scope": "Institution-wide",
+ "offered_role": "Offered role",
+ "expires": "Invitation expires",
+ "accept": "Accept invitation",
+ "accepted": "Your invitation is accepted and the membership is created.",
+ "already_member": "You already have an active membership in this scope. The invitation is accepted; your existing roles and profile are unchanged.",
+ "session_ready": "Your session is ready. Continue to the institution site.",
+ "session_unavailable": "Your membership is confirmed, but a session could not be opened. Check the result to try session admission again.",
+ "result_action": "Check acceptance result",
+ "continue": "Continue to your account"
+ },
+ "failure": {
+ "unknown": "The acceptance outcome could not be confirmed. Check the result or check the invitation again; neither check submits a new acceptance.",
+ "unavailable": "The invitation service is temporarily unavailable. Try again when you are ready.",
+ "expired": "This invitation could not be inspected. If you already accepted it, check the original result. Otherwise, reopen the original email or ask the institution for a new invitation.",
+ "account_change": "The signed-in account changed. Your existing session has been preserved. Sign out explicitly before continuing with another account.",
+ "restart": "Open a new invitation",
+ "retry": "Try again"
+ },
+ "roles": {
+ "tenant_admin": "Institution administrator",
+ "instructor": "Instructor",
+ "learner": "Learner",
+ "guardian": "Guardian",
+ "editor": "Editor",
+ "org_admin": "Branch administrator",
+ "org_instructor": "Branch instructor"
+ }
+}
diff --git a/frontend/apps/web/src/i18n/messages/tr/admin.json b/frontend/apps/web/src/i18n/messages/tr/admin.json
index 64e86c48c..9eb348fdf 100644
--- a/frontend/apps/web/src/i18n/messages/tr/admin.json
+++ b/frontend/apps/web/src/i18n/messages/tr/admin.json
@@ -4,7 +4,9 @@
"members": "Üyeler",
"roles": "Roller",
"profile": "Profilim",
- "contexts": "Hesap erişimini değiştir"
+ "contexts": "Hesap erişimini değiştir",
+ "invitations": "Davetler",
+ "invitation_policy": "Davet geçerlilik süresi"
},
"scope": {
"tenant": "Kurum genelinde erişim",
@@ -39,7 +41,11 @@
"revoke_hint": "Sonlandırma bu üyeliği kalıcı olarak bitirir. Üyelik bu sayfadan yeniden etkinleştirilemez.",
"suspend_hint": "Askıya alma, üyeliği ve rol atamalarını koruyarak erişimi duraklatır."
},
- "status": { "active": "Etkin", "suspended": "Askıda", "revoked": "Sonlandırılmış" },
+ "status": {
+ "active": "Etkin",
+ "suspended": "Askıda",
+ "revoked": "Sonlandırılmış"
+ },
"roles": {
"title": "Roller",
"detail_title": "Rol ayrıntıları",
@@ -74,7 +80,7 @@
"organization_profiles": "Şube üyelik profillerini düzenleme",
"organizations": "Organizasyonları yönetme",
"locales": "Kurum dillerini yönetme",
- "settings": "Kurum görünümünü yönetme",
+ "settings": "Kurum görünümünü ve davet süresini yönetme",
"unavailable": "Yetki adı kullanılamıyor"
},
"failure": {
@@ -86,5 +92,54 @@
"busy": "Çok fazla istek işleniyor. Tekrar denemeden önce bekleyin.",
"retry": "Bu görünümü tekrar kontrol et",
"retry_after": "Tekrar denemeden önce beklenecek saniye"
+ },
+ "invitations": {
+ "title": "Davetler",
+ "detail_title": "Davet ayrıntıları",
+ "create": "Bir kişiyi davet et",
+ "create_hint": "Bu kurum veya şube için tek bir rol teklif ederek davet oluşturun. E-posta otomatik gönderilir.",
+ "recipient": "Alıcının e-posta adresi",
+ "email_hint": "Kişinin hesabında doğrulayacağı e-posta adresini kullanın.",
+ "offered_role": "Teklif edilen rol",
+ "choose_role": "Teklif edilecek rolü seçin",
+ "send": "Daveti oluştur ve e-posta gönder",
+ "no_roles": "Mevcut erişiminizle teklif edebileceğiniz bir rol bulunmuyor.",
+ "list": "Davet geçmişi",
+ "empty": "Bu görünümde davet bulunmuyor.",
+ "next": "Sonraki davetler",
+ "back": "Davetlere dön",
+ "scope": "Üyelik kapsamı",
+ "state": "Davet durumu",
+ "expires": "Bitiş zamanı (UTC)",
+ "delivery": "E-posta gönderim durumu",
+ "pending": "Kabul bekliyor",
+ "accepted": "Kabul edildi",
+ "revoked": "İptal edildi",
+ "expired": "Süresi doldu",
+ "queued": "Sırada",
+ "submission_accepted": "Posta sunucusu gönderimi kabul etti",
+ "failed": "Gönderim başarısız",
+ "uncertain": "Gönderim sonucu belirsiz",
+ "unavailable": "Gönderim durumu alınamadı",
+ "delivery_hint": "Posta sunucusunun kabulü, e-postanın gelen kutusuna ulaştığını veya okunduğunu doğrulamaz.",
+ "uncertain_hint": "E-posta yine de ulaşabilir. Tamamlanmamış işlem gönderim kapasitesini kullanmaya devam eder; bilinmeyen sonuç kesinleşmiş başarısızlık değildir.",
+ "already_member": "Kişinin zaten etkin bir üyeliği vardı. Mevcut rolleri ve profili korundu; teklif edilen rol eklenmedi.",
+ "created_member": "Teklif edilen rolle yeni bir üyelik oluşturuldu.",
+ "role_unavailable": "Teklif edilen rolün adı mevcut erişiminizle artık görüntülenemiyor.",
+ "role_changed": "Teklif edilen rolün özgün sürümü artık kullanılamıyor. Güncel rolü seçmek için bu daveti iptal edip yeni bir davet oluşturun.",
+ "other_scope": "Bu davet mevcut erişim bağlamınızın dışında. Yönetmek için hesap erişiminizi değiştirin.",
+ "reissue": "Daveti yenile ve e-posta gönder",
+ "reissue_hint": "Bu işlem mevcut kodu ve bitiş zamanını değiştirir. Eski bir e-posta yine de ulaşabilir ancak kodu artık çalışmaz. Tamamlanmamış eski e-posta işlemi gönderim kapasitesindeki yerini korur.",
+ "revoke": "Daveti iptal et",
+ "revoke_hint": "Bu işlem teklifi sonlandırır. Gönderimi başlamış bir e-posta yine de ulaşabilir ancak üyelik sağlayamaz.",
+ "policy_title": "Davet geçerlilik süresi",
+ "policy_hint": "Yeni ve açıkça yenilenen davetlerin geçerlilik süresini seçin. Mevcut davetlerin bitiş zamanları değişmez.",
+ "policy_stored": "Bu kurumun kayıtlı bir geçerlilik süresi kuralı var.",
+ "policy_default": "Kayıtlı bir kural yok. Varsayılan geçerlilik süresi 14 gündür.",
+ "policy_days": "Gün olarak geçerlilik süresi",
+ "policy_range": "1 ile 30 arasında bir tam sayı girin.",
+ "policy_save": "Davet geçerlilik süresini kaydet",
+ "policy_read_only": "Bu kuralı görüntüleyebilirsiniz. Düzenlemek için kurum genelinde ayar yetkisi gerekir.",
+ "required": "Zorunlu"
}
}
diff --git a/frontend/apps/web/src/i18n/messages/tr/invitation.json b/frontend/apps/web/src/i18n/messages/tr/invitation.json
new file mode 100644
index 000000000..f744afd58
--- /dev/null
+++ b/frontend/apps/web/src/i18n/messages/tr/invitation.json
@@ -0,0 +1,60 @@
+{
+ "common": {
+ "title": "Davet",
+ "skip": "İçeriğe geç",
+ "cancel": "Daveti iptal et",
+ "home": "Kurum sitesine dön",
+ "signout": "Başka bir hesap kullanmak için çıkış yap",
+ "required": "Zorunlu"
+ },
+ "landing": {
+ "title": "Davetinizi açın",
+ "description": "E-postanızdaki davetle devam edin veya aynı e-postadaki kodun tamamını aşağıya yapıştırın.",
+ "code_label": "Davet kodu",
+ "continue": "Devam et",
+ "no_script": "JavaScript kapalı. Temiz /invitations adresini açıp e-postadaki ayrı kodu yapıştırın. Adresteki parça, JavaScript olmadan otomatik olarak kaldırılamaz.",
+ "invalid": "Bu davet kodu geçerli değil. İlk e-postadaki kodun tamamını yapıştırın."
+ },
+ "confirm": {
+ "signin": "Davetin gönderildiği hesapla giriş yapın. Giriş sayfasından yeni bir hesap da oluşturabilirsiniz.",
+ "signin_action": "Giriş yap veya hesap oluştur",
+ "bootstrap": "Devam etmek için LearnStack kimliğinizi oluşturun. Bu işlem henüz kuruma üye olmanızı sağlamaz.",
+ "bootstrap_action": "LearnStack kimliğimi oluştur",
+ "inspect": "Daveti hesabınızın güncel doğrulanmış e-posta adresiyle kontrol edin. Daha önce kabul ettiyseniz önceki kabul sonucunu kontrol edin.",
+ "inspect_action": "Daveti kontrol et",
+ "forbidden": "Bu hesap şu anda daveti doğrulayamıyor. E-postanızı doğrulayın veya çıkış yapıp davetin gönderildiği hesapla giriş yapın. Daha önce kabul ettiyseniz önceki kabul sonucunu kontrol edin.",
+ "verify_action": "E-postamı doğrula",
+ "verification_hint": "E-posta doğrulamasını tamamladıktan sonra buraya dönüp daveti yeniden kontrol edin. E-posta doğrulaması tek başına daveti kabul etmez.",
+ "ready": "Kabul etmeden önce doğrulanmış hesabınızı ve sunulan üyeliği kontrol edin.",
+ "email": "Doğrulanmış e-posta adresiniz",
+ "institution": "Kurum",
+ "branch": "Şube",
+ "tenant_scope": "Kurum genelinde",
+ "offered_role": "Sunulan rol",
+ "expires": "Davetin son geçerlilik zamanı",
+ "accept": "Daveti kabul et",
+ "accepted": "Davetiniz kabul edildi ve üyeliğiniz oluşturuldu.",
+ "already_member": "Bu kapsamda zaten etkin bir üyeliğiniz var. Davet kabul edildi; mevcut rolleriniz ve profiliniz değişmedi.",
+ "session_ready": "Oturumunuz hazır. Kurum sitesinden devam edebilirsiniz.",
+ "session_unavailable": "Üyeliğiniz onaylandı ancak oturum açılamadı. Oturum açmayı yeniden denemek için kabul sonucunu kontrol edin.",
+ "result_action": "Kabul sonucunu kontrol et",
+ "continue": "Hesabınıza devam edin"
+ },
+ "failure": {
+ "unknown": "Kabul işleminin sonucu doğrulanamadı. Sonucu kontrol edin veya daveti yeniden inceleyin; bu kontroller yeni bir kabul isteği göndermez.",
+ "unavailable": "Davet hizmeti geçici olarak kullanılamıyor. Hazır olduğunuzda yeniden deneyin.",
+ "expired": "Bu davet incelenemedi. Daha önce kabul ettiyseniz önceki kabul sonucunu kontrol edin. Aksi durumda ilk e-postayı yeniden açın veya kurumdan yeni bir davet isteyin.",
+ "account_change": "Giriş yapılan hesap değişti. Mevcut oturumunuz korundu. Başka bir hesapla devam etmeden önce açıkça çıkış yapın.",
+ "restart": "Yeni bir davet aç",
+ "retry": "Yeniden dene"
+ },
+ "roles": {
+ "tenant_admin": "Kurum yöneticisi",
+ "instructor": "Eğitmen",
+ "learner": "Öğrenci",
+ "guardian": "Veli",
+ "editor": "Editör",
+ "org_admin": "Şube yöneticisi",
+ "org_instructor": "Şube eğitmeni"
+ }
+}
diff --git a/frontend/apps/web/src/server/auth/cookies.ts b/frontend/apps/web/src/server/auth/cookies.ts
index efe7905ba..afae95f39 100644
--- a/frontend/apps/web/src/server/auth/cookies.ts
+++ b/frontend/apps/web/src/server/auth/cookies.ts
@@ -6,6 +6,7 @@ export { AUTH_INPUT_LIMIT } from './http-paths.js';
export const BFF_COOKIES = Object.freeze({
session: '__Host-ls-session',
entry: '__Host-ls-entry',
+ invitation: '__Host-ls-invitation',
browser: '__Host-ls-browser',
csrf: '__Host-ls-csrf',
login: '__Host-ls-login-',
@@ -15,6 +16,7 @@ const prefix = '__Host-ls-';
const fixed = new Set([
BFF_COOKIES.session,
BFF_COOKIES.entry,
+ BFF_COOKIES.invitation,
BFF_COOKIES.browser,
BFF_COOKIES.csrf,
]);
diff --git a/frontend/apps/web/src/server/auth/entry-cursor.ts b/frontend/apps/web/src/server/auth/entry-cursor.ts
index 6e2c433d8..08561710a 100644
--- a/frontend/apps/web/src/server/auth/entry-cursor.ts
+++ b/frontend/apps/web/src/server/auth/entry-cursor.ts
@@ -7,7 +7,7 @@ type EntryCursorBinding = Readonly<{
id: string;
host: string;
epoch: string;
- purpose?: 'members' | 'scopes';
+ purpose?: 'members' | 'scopes' | 'invitations';
}>;
const VERSION = 1;
const MAX_PLAINTEXT_BYTES = 739;
diff --git a/frontend/apps/web/src/server/auth/html.ts b/frontend/apps/web/src/server/auth/html.ts
index b7dd9b4ff..0b8b2b3a5 100644
--- a/frontend/apps/web/src/server/auth/html.ts
+++ b/frontend/apps/web/src/server/auth/html.ts
@@ -111,12 +111,24 @@ function mutationEdits(
roleIds: copy('mutation.edit_role_ids'),
grants: copy('mutation.edit_grants'),
manifestVersion: copy('mutation.edit_manifest_version'),
+ email: admin('invitations.recipient'),
+ offeredRole: admin('invitations.offered_role'),
+ days: admin('invitations.policy_days'),
+ presence: admin('invitations.policy_title'),
};
const edits = page.fields
.filter(([name]) => Object.hasOwn(labels, name))
.map(([name, value]) => {
let visible = value;
- if (name === 'roleIds') {
+ if (name === 'offeredRole') {
+ const role = page.roleLabels?.find((role) => role.roleId === value.split('.', 1)[0]);
+ visible = role ? roleLabel(role, admin) : admin('invitations.role_unavailable');
+ } else if (name === 'presence') {
+ visible =
+ value === 'absent'
+ ? admin('invitations.policy_default')
+ : admin('invitations.policy_stored');
+ } else if (name === 'roleIds') {
const ids: unknown = JSON.parse(value);
const named = Array.isArray(ids)
? ids.map((id) => page.roleLabels?.find((role) => role.roleId === id))
@@ -149,6 +161,9 @@ function mutationContent(
locale: string,
site: PageSite,
): string {
+ if (page.reason === 'validation' && page.action.startsWith('/api/studio/invitation-')) {
+ return `${escape(copy('mutation.validation'))}
${mutationEdits(page, copy, locale)}${escape(copy('mutation.lookup'))}
`;
+ }
if (page.reason === 'validation') {
const { profile } = createAdministrationTranslator(locale);
const values = new Map(page.fields);
diff --git a/frontend/apps/web/src/server/auth/http-admission.ts b/frontend/apps/web/src/server/auth/http-admission.ts
index 102e85084..7ec2f6484 100644
--- a/frontend/apps/web/src/server/auth/http-admission.ts
+++ b/frontend/apps/web/src/server/auth/http-admission.ts
@@ -57,7 +57,9 @@ export async function admitBffHttp(
if (location?.startsWith('/')) {
if (
!isNativeReturnPath(location) &&
- !['/api/auth/entry', '/api/auth/signin'].includes(location)
+ !['/api/auth/entry', '/api/auth/signin', '/invitations', '/invitations/confirm'].includes(
+ location,
+ )
)
throw new Error('BFF response destination unavailable');
headers.set('location', 'https://' + context.host + location);
diff --git a/frontend/apps/web/src/server/auth/http-forms.ts b/frontend/apps/web/src/server/auth/http-forms.ts
index 9ecc07771..8ed317f5e 100644
--- a/frontend/apps/web/src/server/auth/http-forms.ts
+++ b/frontend/apps/web/src/server/auth/http-forms.ts
@@ -121,6 +121,12 @@ export class BffProfileInputError extends BffInputError {
super();
}
}
+/** User-editable invitation values are recoverable after proof/target grammar succeeds. */
+export class BffInvitationInputError extends BffInputError {
+ constructor(readonly invalidFields: readonly ('email' | 'offeredRole' | 'days')[]) {
+ super();
+ }
+}
export const BFF_ADMIN_FORMS: Readonly> = Object.freeze({
'/api/studio/membership-profile': [...common, 'membershipId', 'displayName', 'preferredLocale'],
'/api/studio/membership-roles': [...common, 'membershipId', 'roleIds'],
@@ -133,6 +139,10 @@ export const BFF_ADMIN_FORMS: Readonly> = Obje
'manifestFingerprint',
],
'/api/account/profile': [...common, 'displayName', 'preferredLocale'],
+ '/api/studio/invitation-create': ['csrfToken', 'idempotencyKey', 'email', 'offeredRole'],
+ '/api/studio/invitation-revoke': [...common, 'invitationId'],
+ '/api/studio/invitation-reissue': [...common, 'invitationId'],
+ '/api/studio/invitation-policy': ['csrfToken', 'idempotencyKey', 'presence', 'version', 'days'],
});
function profile(fields: URLSearchParams) {
const invalid: ProfileField[] = [];
@@ -159,15 +169,95 @@ export function bffAdministrationForm(
path: string,
fields: URLSearchParams,
): Readonly<{ operation: NativeOperation; destination: string }> {
- const version = required(fields, 'version');
- const idempotencyKey = required(fields, 'idempotencyKey');
- if (
- !/^(0|[1-9][0-9]{0,18})$/.test(version) ||
- (version.length === 19 && version > '9223372036854775807') ||
- !/^[\x21-\x7e]{8,128}$/.test(idempotencyKey)
- )
+ const allowed = BFF_ADMIN_FORMS[path];
+ if (!allowed) throw new BffInputError();
+ const names = [...fields.keys()];
+ if (new Set(names).size !== names.length || names.some((name) => !allowed.includes(name)))
throw new BffInputError();
- const write = { version, idempotencyKey };
+ const idempotencyKey = required(fields, 'idempotencyKey');
+ if (!/^[\x21-\x7e]{8,128}$/.test(idempotencyKey)) throw new BffInputError();
+ const readVersion = (name = 'version') => {
+ const value = required(fields, name);
+ if (
+ !/^(0|[1-9][0-9]{0,18})$/.test(value) ||
+ (value.length === 19 && value > '9223372036854775807')
+ )
+ throw new BffInputError();
+ return value;
+ };
+ if (path === '/api/studio/invitation-create') {
+ const email = required(fields, 'email');
+ if (
+ [...email].some((character) => {
+ const point = character.codePointAt(0)!;
+ return point < 32 || (point >= 127 && point <= 159);
+ })
+ )
+ throw new BffInputError();
+ const offeredRole = required(fields, 'offeredRole');
+ const invalid: ('email' | 'offeredRole')[] = [];
+ if (
+ !email ||
+ email.length > 254 ||
+ !/^[\x21-\x7e]+$/.test(email) ||
+ !/^[^@]+@[^@]+$/.test(email)
+ )
+ invalid.push('email');
+ if (!offeredRole) invalid.push('offeredRole');
+ if (invalid.length) throw new BffInvitationInputError(Object.freeze(invalid));
+ const choice = offeredRole.split('.');
+ if (choice.length !== 2) throw new BffInputError();
+ const roleId = id(choice[0]!);
+ const roleVersion = choice[1]!;
+ if (
+ !/^(0|[1-9][0-9]{0,18})$/.test(roleVersion) ||
+ (roleVersion.length === 19 && roleVersion > '9223372036854775807')
+ )
+ throw new BffInputError();
+ return {
+ operation: {
+ kind: 'createInvitation',
+ invitation: { email, roleId, roleVersion },
+ idempotencyKey,
+ },
+ destination: '/studio/invitations',
+ };
+ }
+ if (path === '/api/studio/invitation-policy') {
+ const presence = required(fields, 'presence');
+ const days = required(fields, 'days');
+ if (
+ (presence !== 'absent' && presence !== 'present') ||
+ (presence === 'absent' && fields.has('version'))
+ )
+ throw new BffInputError();
+ const precondition =
+ presence === 'absent'
+ ? ({ kind: 'absent' } as const)
+ : ({ kind: 'present', version: readVersion() } as const);
+ if (!/^(?:[1-9]|[12][0-9]|30)$/.test(days)) throw new BffInvitationInputError(['days']);
+ return {
+ operation: {
+ kind: 'setInvitationLifetime',
+ days: Number(days),
+ idempotencyKey,
+ precondition,
+ },
+ destination: '/studio/invitation-policy',
+ };
+ }
+ const write = { version: readVersion(), idempotencyKey };
+ if (path === '/api/studio/invitation-revoke' || path === '/api/studio/invitation-reissue') {
+ const invitationId = id(required(fields, 'invitationId'));
+ return {
+ operation: {
+ kind: path === '/api/studio/invitation-revoke' ? 'revokeInvitation' : 'reissueInvitation',
+ ...write,
+ id: invitationId,
+ },
+ destination: '/studio/invitations/' + invitationId,
+ };
+ }
if (path === '/api/account/profile')
return {
operation: { kind: 'setOwnProfile', ...write, profile: profile(fields) },
diff --git a/frontend/apps/web/src/server/auth/http-paths.ts b/frontend/apps/web/src/server/auth/http-paths.ts
index 17857ea65..cda314758 100644
--- a/frontend/apps/web/src/server/auth/http-paths.ts
+++ b/frontend/apps/web/src/server/auth/http-paths.ts
@@ -2,6 +2,16 @@ export const AUTH_INPUT_LIMIT = 16 * 1024;
/** The only native POST exemptions, gated off until the complete BFF is installed. */
export const BFF_POST_PATHS = Object.freeze([
+ '/api/auth/invitation/capture',
+ '/api/auth/invitation/inspect',
+ '/api/auth/invitation/verify',
+ '/api/auth/invitation/accept',
+ '/api/auth/invitation/result',
+ '/api/auth/invitation/cancel',
+ '/api/studio/invitation-create',
+ '/api/studio/invitation-revoke',
+ '/api/studio/invitation-reissue',
+ '/api/studio/invitation-policy',
'/api/auth/signin/keycloak',
'/api/auth/signout',
'/api/auth/extend',
diff --git a/frontend/apps/web/src/server/auth/http-protocol.ts b/frontend/apps/web/src/server/auth/http-protocol.ts
index cd19dc459..751cdc89d 100644
--- a/frontend/apps/web/src/server/auth/http-protocol.ts
+++ b/frontend/apps/web/src/server/auth/http-protocol.ts
@@ -11,6 +11,7 @@ import {
import {
BFF_ADMIN_FORMS,
BffProfileInputError,
+ BffInvitationInputError,
bffAdministrationForm,
bffContextHint,
bffCursor,
@@ -313,6 +314,9 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati
const httpSignal = request.signal;
let postDeadline: AbortSignal | undefined;
const path = request.target.split('?', 1)[0]!;
+ // Recipient actions belong only to the pre-Next native handler.
+ if (path.startsWith('/api/auth/invitation/'))
+ return result(request, { code: 'not_found' }, 404);
// An inert public query is not a logout action. Only an owned proof-cookie
// family selects the root return protocol; unrecognized states touch nothing.
const rootFields =
@@ -448,6 +452,8 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati
'code',
'iss',
'session_state',
+ 'kc_action',
+ 'kc_action_status',
'error',
'error_description',
'error_uri',
@@ -467,16 +473,31 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati
signal: request.signal,
});
if (request.signal.aborted) throw new BffInputError();
- return redirect(request, '/api/auth/entry', [
- bffCookie(BFF_COOKIES.entry, completed.handle, 300),
- bffCookie(BFF_COOKIES.browser, completed.browser, cookieLife),
- clearBffCookie(BFF_COOKIES.login + state),
- ]);
+ return redirect(
+ request,
+ completed.invitation ? '/invitations/confirm' : '/api/auth/entry',
+ [
+ bffCookie(BFF_COOKIES.entry, completed.handle, 300),
+ bffCookie(BFF_COOKIES.browser, completed.browser, cookieLife),
+ clearBffCookie(BFF_COOKIES.login + state),
+ ],
+ );
}
bffQuery(request.target, []);
exactBffOrigin(request);
if (path === '/api/auth/signin/keycloak') {
- const fields = await bffForm(request, ['csrfToken', 'returnTo', 'contextHint']);
+ const fields = await bffForm(request, [
+ 'csrfToken',
+ 'returnTo',
+ 'contextHint',
+ 'invitation',
+ ]);
+ const invitation = fields.get('invitation');
+ if (
+ invitation !== null &&
+ (invitation !== 'continue' || !cookies.has(BFF_COOKIES.invitation))
+ )
+ throw new BffInputError();
if (!bffSecretEquals(fields.get('csrfToken'), cookies.get(BFF_COOKIES.csrf)))
return result(request, { code: 'csrf' }, 403);
const started = await store.start({
@@ -486,6 +507,9 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati
handle: cookies.get(BFF_COOKIES.session),
returnPath: returnPath(fields.get('returnTo')),
contextHint: bffContextHint(fields.get('contextHint')),
+ ...(invitation === 'continue'
+ ? { invitation: cookies.get(BFF_COOKIES.invitation)! }
+ : {}),
signal: request.signal,
});
if (request.signal.aborted) throw new BffInputError();
@@ -511,6 +535,11 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati
if (switching && !bffSecretEquals(csrf, store.peek(selectedBinding)?.csrfToken))
return result(request, { code: 'csrf' }, 403);
if (creating) {
+ const invitationBinding = binding(request, cookies, BFF_COOKIES.invitation);
+ if (invitationBinding && store.invitationEntry(invitationBinding, selectedBinding)) {
+ await store.create(selectedBinding, request.source, csrf, request.signal);
+ return redirect(request, '/invitations/confirm', []);
+ }
const snapshot = await store.preparePage(selectedBinding, request.source, {
kind: 'create',
ticket: ticket(request),
@@ -573,7 +602,11 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati
try {
form = bffAdministrationForm(path, fields);
} catch (error) {
- if (!(error instanceof BffProfileInputError)) throw error;
+ if (
+ !(error instanceof BffProfileInputError) &&
+ !(error instanceof BffInvitationInputError)
+ )
+ throw error;
return await actionDocument(
request,
render,
@@ -582,9 +615,13 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati
kind: 'mutation_error',
action: path,
destination:
- path === '/api/account/profile'
- ? '/portal/profile'
- : '/studio/members/' + fields.get('membershipId')!,
+ path === '/api/studio/invitation-create'
+ ? '/studio/invitations'
+ : path === '/api/studio/invitation-policy'
+ ? '/studio/invitation-policy'
+ : path === '/api/account/profile'
+ ? '/portal/profile'
+ : '/studio/members/' + fields.get('membershipId')!,
fields: [...fields.entries()],
reason: 'validation',
invalidFields: error.invalidFields,
@@ -635,21 +672,39 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati
503,
);
}
- if (outcome.kind === 'success') return redirect(request, form.destination, []);
+ if (outcome.kind === 'success') {
+ if (path === '/api/studio/invitation-create') {
+ const location = outcome.location;
+ const match =
+ /^\/api\/v1\/identity\/invitations\/([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/.exec(
+ location ?? '',
+ );
+ if (!match) throw new BffSessionError('response');
+ return redirect(request, '/studio/invitations/' + match[1], []);
+ }
+ return redirect(request, form.destination, []);
+ }
const status =
outcome.kind === 'api-error'
? outcome.status
: outcome.kind === 'invalid-request'
? 400
: 503;
- const code = outcome.kind === 'api-error' ? outcome.error.code : 'unavailable';
+ const code =
+ outcome.kind === 'api-error'
+ ? outcome.error.code
+ : outcome.kind === 'invalid-request' && path.startsWith('/api/studio/invitation-')
+ ? 'validation_failed'
+ : 'unavailable';
const profileErrors =
outcome.kind === 'api-error' && outcome.error.code === 'validation_failed'
? Object.keys(outcome.error.fieldErrors)
: [];
const reason =
code === 'validation_failed' &&
- (path === '/api/account/profile' || path === '/api/studio/membership-profile')
+ (path === '/api/account/profile' ||
+ path === '/api/studio/membership-profile' ||
+ path.startsWith('/api/studio/invitation-'))
? 'validation'
: code === 'concurrency_conflict'
? 'stale'
@@ -674,7 +729,9 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati
outcome.kind === 'api-error' &&
outcome.error.code === 'validation_failed'
? {
- invalidFields: (['displayName', 'preferredLocale'] as const).filter((name) =>
+ invalidFields: (
+ ['displayName', 'preferredLocale', 'email', 'offeredRole', 'days'] as const
+ ).filter((name) =>
profileErrors.some((key) => key.toLowerCase() === name.toLowerCase()),
),
}
@@ -718,6 +775,17 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati
];
return redirect(request, logout.redirectUrl ?? '/', edits);
} catch (error) {
+ if (
+ path === '/api/auth/callback/keycloak' &&
+ error instanceof BffSessionError &&
+ error.clearTransaction
+ ) {
+ const cookies = bffCookies(request.headers.get('cookie'));
+ if (cookies.has(BFF_COOKIES.invitation))
+ return redirect(request, '/invitations/confirm', [
+ clearBffCookie(BFF_COOKIES.login + error.clearTransaction),
+ ]);
+ }
const entryRequest = [
'/api/auth/entry',
'/api/auth/entry/create',
diff --git a/frontend/apps/web/src/server/auth/invitation-administration-forms.test.ts b/frontend/apps/web/src/server/auth/invitation-administration-forms.test.ts
new file mode 100644
index 000000000..6742203b4
--- /dev/null
+++ b/frontend/apps/web/src/server/auth/invitation-administration-forms.test.ts
@@ -0,0 +1,104 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+
+import { bffAdministrationForm, BffInvitationInputError } from './http-forms';
+
+const id = '019a154b-0000-7000-8000-000000000022';
+const version = '9223372036854775807';
+const key = 'exact-invitation-key';
+const common = { csrfToken: 'A'.repeat(43), idempotencyKey: key };
+const create = { ...common, email: 'Person@example.test', offeredRole: id + '.' + version };
+
+describe('strict invitation administration form input', () => {
+ it('unpacks one no-script Role choice while preserving the pinned Int64 string', () => {
+ expect(
+ bffAdministrationForm('/api/studio/invitation-create', new URLSearchParams(create)),
+ ).toEqual({
+ operation: {
+ kind: 'createInvitation',
+ invitation: { email: create.email, roleId: id, roleVersion: version },
+ idempotencyKey: key,
+ },
+ destination: '/studio/invitations',
+ });
+ });
+ it('rejects selectors, duplicate choice, malformed IDs, versions and aliases before any operation', () => {
+ for (const fields of [
+ new URLSearchParams({ ...create, organizationId: id }),
+ new URLSearchParams({ ...create, version: '1' }),
+ new URLSearchParams({ ...create, roleId: id, roleVersion: '1' }),
+ new URLSearchParams({ ...create, offeredRole: id + '.9223372036854775808' }),
+ new URLSearchParams({ ...create, offeredRole: id + '.00' }),
+ new URLSearchParams({ ...create, offeredRole: id.toUpperCase() + '.1' }),
+ new URLSearchParams({ ...create, email: 'Person\n@example.test' }),
+ new URLSearchParams([...Object.entries(create), ['offeredRole', id + '.1']]),
+ ])
+ expect(() => bffAdministrationForm('/api/studio/invitation-create', fields)).toThrow();
+ });
+ it('classifies editable invitation values for safe recovery while rejecting contradictory target grammar', () => {
+ for (const [fields, invalidFields] of [
+ [{ ...create, email: '', offeredRole: '' }, ['email', 'offeredRole']],
+ [{ ...create, email: 'not-an-address' }, ['email']],
+ ] as const) {
+ try {
+ bffAdministrationForm('/api/studio/invitation-create', new URLSearchParams(fields));
+ throw new Error('Expected invalid form');
+ } catch (error) {
+ expect(error).toBeInstanceOf(BffInvitationInputError);
+ expect((error as BffInvitationInputError).invalidFields).toEqual(invalidFields);
+ }
+ }
+ expect(() =>
+ bffAdministrationForm(
+ '/api/studio/invitation-policy',
+ new URLSearchParams({ ...common, presence: 'absent', days: '31' }),
+ ),
+ ).toThrow(BffInvitationInputError);
+ });
+ it.each(['revoke', 'reissue'])('binds %s to an exact UUID and strong version', (action) => {
+ expect(
+ bffAdministrationForm(
+ '/api/studio/invitation-' + action,
+ new URLSearchParams({ ...common, invitationId: id, version }),
+ ),
+ ).toMatchObject({
+ operation: {
+ kind: action === 'revoke' ? 'revokeInvitation' : 'reissueInvitation',
+ id,
+ version,
+ idempotencyKey: key,
+ },
+ destination: '/studio/invitations/' + id,
+ });
+ });
+ it('separates absent policy from present zero and refuses contradictory conditions', () => {
+ for (const presence of ['absent', 'present']) {
+ const fields = new URLSearchParams({ ...common, presence, days: '14' });
+ if (presence === 'present') fields.set('version', '0');
+ expect(bffAdministrationForm('/api/studio/invitation-policy', fields)).toMatchObject({
+ operation: {
+ kind: 'setInvitationLifetime',
+ days: 14,
+ precondition:
+ presence === 'absent' ? { kind: 'absent' } : { kind: 'present', version: '0' },
+ },
+ });
+ }
+ for (const extra of [
+ { presence: 'absent', version: '0', days: '14' },
+ { presence: 'present', days: '14' },
+ { presence: 'absent', days: '0' },
+ { presence: 'absent', days: '31' },
+ { presence: 'absent', days: '1.5' },
+ { presence: 'absent', days: '014' },
+ { presence: 'missing', days: '14' },
+ { presence: 'present', version: '00', days: '14' },
+ ] as Record[])
+ expect(() =>
+ bffAdministrationForm(
+ '/api/studio/invitation-policy',
+ new URLSearchParams({ ...common, ...extra }),
+ ),
+ ).toThrow();
+ });
+});
diff --git a/frontend/apps/web/src/server/auth/invitation-copy.ts b/frontend/apps/web/src/server/auth/invitation-copy.ts
new file mode 100644
index 000000000..6e90294c6
--- /dev/null
+++ b/frontend/apps/web/src/server/auth/invitation-copy.ts
@@ -0,0 +1,92 @@
+export const invitationKeys = [
+ 'common.title',
+ 'common.required',
+ 'common.skip',
+ 'common.cancel',
+ 'common.home',
+ 'common.signout',
+ 'landing.title',
+ 'landing.description',
+ 'landing.code_label',
+ 'landing.continue',
+ 'landing.no_script',
+ 'landing.invalid',
+ 'confirm.signin',
+ 'confirm.signin_action',
+ 'confirm.bootstrap',
+ 'confirm.bootstrap_action',
+ 'confirm.inspect',
+ 'confirm.inspect_action',
+ 'confirm.forbidden',
+ 'confirm.verify_action',
+ 'confirm.verification_hint',
+ 'confirm.ready',
+ 'confirm.email',
+ 'confirm.institution',
+ 'confirm.branch',
+ 'confirm.tenant_scope',
+ 'confirm.offered_role',
+ 'confirm.expires',
+ 'confirm.accept',
+ 'confirm.accepted',
+ 'confirm.already_member',
+ 'confirm.session_ready',
+ 'confirm.continue',
+ 'confirm.session_unavailable',
+ 'confirm.result_action',
+ 'failure.unknown',
+ 'failure.unavailable',
+ 'failure.expired',
+ 'failure.account_change',
+ 'failure.restart',
+ 'failure.retry',
+ 'roles.tenant_admin',
+ 'roles.instructor',
+ 'roles.learner',
+ 'roles.guardian',
+ 'roles.editor',
+ 'roles.org_admin',
+ 'roles.org_instructor',
+] as const;
+export type InvitationKey = (typeof invitationKeys)[number];
+export type InvitationCopy = (key: InvitationKey) => string;
+export type InvitationCatalogues = Readonly<{ en: unknown; tr: unknown }>;
+
+/** Native startup supplies the same authored JSON used by catalogue validation. */
+export function prepareInvitationCatalogues(catalogues: InvitationCatalogues) {
+ const prepared = new Map>();
+ for (const locale of ['en', 'tr'] as const) {
+ const values = new Map();
+ const visit = (value: unknown, prefix = '') => {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw new Error('Invitation catalogue unavailable');
+ for (const [key, child] of Object.entries(value)) {
+ const path = prefix ? prefix + '.' + key : key;
+ if (typeof child === 'string') {
+ if (
+ !invitationKeys.includes(path as InvitationKey) ||
+ !child.trim() ||
+ child.length > 1000 ||
+ /[{}]/.test(child) ||
+ child.includes('\0')
+ )
+ throw new Error('Invitation catalogue unavailable');
+ values.set(path as InvitationKey, child);
+ } else visit(child, path);
+ }
+ };
+ visit(catalogues[locale]);
+ if (values.size !== invitationKeys.length) throw new Error('Invitation catalogue unavailable');
+ prepared.set(locale, values);
+ }
+ return (contentLocale: string) => {
+ let locale = contentLocale;
+ while (!prepared.has(locale) && locale.includes('-'))
+ locale = locale.slice(0, locale.lastIndexOf('-'));
+ if (!prepared.has(locale)) locale = 'en';
+ const messages = prepared.get(locale)!;
+ const invitation: InvitationCopy = (key) =>
+ messages.get(key) ?? messages.get('failure.unavailable')!;
+ return Object.freeze({ locale, invitation });
+ };
+}
diff --git a/frontend/apps/web/src/server/auth/invitation-html.test.ts b/frontend/apps/web/src/server/auth/invitation-html.test.ts
new file mode 100644
index 000000000..2e5034767
--- /dev/null
+++ b/frontend/apps/web/src/server/auth/invitation-html.test.ts
@@ -0,0 +1,270 @@
+import { readFileSync } from 'node:fs';
+import { runInNewContext } from 'node:vm';
+
+import { describe, expect, it, vi } from 'vitest';
+
+import { invitationKeys, prepareInvitationCatalogues } from './invitation-copy';
+import {
+ createInvitationRenderer,
+ INVITATION_CONFIRM_SCRIPT,
+ INVITATION_LANDING_SCRIPT,
+} from './invitation-html';
+import type { InvitationInspection, InvitationPage } from './native-invitation-contract';
+import en from '../../i18n/messages/en/invitation.json';
+import tr from '../../i18n/messages/tr/invitation.json';
+import { checkCatalogues, publicCallsiteKeys } from '../../test/ui-catalogue-checks';
+
+const csrfToken = 'c'.repeat(43);
+const render = createInvitationRenderer({ en, tr });
+const inspection: InvitationInspection = {
+ invitationId: '019a154b-0000-7000-8000-000000000031',
+ version: '9007199254740993',
+ expiresAt: '2026-11-20T15:30:00.0000000+00:00',
+ tenantLabel: 'School ',
+ organizationLabel: 'Branch & center',
+ roleId: '019a154b-0000-7000-8000-000000000032',
+ roleKey: 'Learner',
+ roleScope: 'Organization',
+ verifiedEmail: 'recipient+full@example.test',
+};
+const page = (
+ value: Partial> = {},
+): InvitationPage => ({
+ kind: 'confirm',
+ locale: 'en',
+ csrfToken,
+ state: 'inspect',
+ ...value,
+});
+const parse = (html: string) => new DOMParser().parseFromString(html, 'text/html');
+
+describe('native invitation documents and complete authored copy', () => {
+ it('checks the actual typed callsites, equal en/tr keys and ICU grammar', () => {
+ const source = readFileSync('src/server/auth/invitation-html.ts', 'utf8');
+ const used = publicCallsiteKeys(source, 'invitation-html.ts', 'invitation');
+ expect(used.length).toBeGreaterThan(30);
+ checkCatalogues([en, tr], used);
+ expect(new Set(invitationKeys).size).toBe(invitationKeys.length);
+ const select = prepareInvitationCatalogues({ en, tr });
+ expect(select('tr-Latn-TR').invitation('common.title')).toBe(tr.common.title);
+ expect(select('ar').invitation('common.title')).toBe(en.common.title);
+ const incomplete = structuredClone(tr);
+ delete (incomplete.confirm as Partial).already_member;
+ expect(() => createInvitationRenderer({ en, tr: incomplete })).toThrow(
+ 'Invitation catalogue unavailable',
+ );
+ expect(() => checkCatalogues([en, tr], [...used, 'confirm.misspelled'])).toThrow('Used UI key');
+ expect(() =>
+ publicCallsiteKeys('invitation(problem.messageKey)', 'dirty.ts', 'invitation'),
+ ).toThrow('literal');
+ });
+
+ it('renders a clean code-paste form without scripts being required and places scrub before visible UI', () => {
+ const html = render({ kind: 'landing', locale: 'tr-TR', csrfToken });
+ const document = parse(html);
+ expect(document.querySelector('script')?.textContent).toBe(INVITATION_LANDING_SCRIPT);
+ expect(html.indexOf('${escape(invitation('common.title'))}${escape(invitation('common.skip'))}${escape(landing ? invitation('landing.title') : invitation('common.title'))}
${content}${escape(invitation('common.home'))}