From 6c3a982daff21d78eeed3fb5217d30949c2c19cf Mon Sep 17 00:00:00 2001
From: Rotimi Best
Date: Sun, 19 Jul 2026 12:02:11 +0100
Subject: [PATCH] feat(course): auto-enrol button for paid course join requests
(#769)
* feat(course): add auto-enrol flow for paid course join requests
- Extend teacher buy-request email with manual access steps and an
Auto Enrol this Student button
- Link teachers to the course People page with grantAccess query param
- Add grant-access modal that sends the same org audience invite used
by the Invite new students flow
Co-authored-by: Rotimi Best
* fix(i18n): translate grant access modal strings
Replace English placeholders copied into non-en locale files with
proper translations for each supported language.
Co-authored-by: Rotimi Best
* fix(email): shorten auto-enrol button label per review
Co-authored-by: Rotimi Best
* fix(email): use American spelling Enroll in auto-enroll CTA
Co-authored-by: Rotimi Best
* fix(i18n): localize Danish grant-access send invite label
Co-authored-by: Rotimi Best
---------
Co-authored-by: Rotimi Best
---
.../src/services/course/payment-request.ts | 12 +++
.../people/grant-access-modal.svelte | 91 +++++++++++++++++++
.../lib/features/course/pages/people.svelte | 2 +
.../src/lib/utils/translations/da.json | 6 ++
.../src/lib/utils/translations/de.json | 6 ++
.../src/lib/utils/translations/en.json | 6 ++
.../src/lib/utils/translations/es.json | 6 ++
.../src/lib/utils/translations/fr.json | 6 ++
.../src/lib/utils/translations/hi.json | 6 ++
.../src/lib/utils/translations/pl.json | 6 ++
.../src/lib/utils/translations/pt.json | 6 ++
.../src/lib/utils/translations/ru.json | 6 ++
.../src/lib/utils/translations/vi.json | 6 ++
.../src/emails/teacher-student-buy-request.ts | 15 +++
14 files changed, 180 insertions(+)
create mode 100644 apps/dashboard/src/lib/features/course/components/people/grant-access-modal.svelte
diff --git a/apps/api/src/services/course/payment-request.ts b/apps/api/src/services/course/payment-request.ts
index 828a642d..ffe1ac45 100644
--- a/apps/api/src/services/course/payment-request.ts
+++ b/apps/api/src/services/course/payment-request.ts
@@ -5,6 +5,7 @@ import { getCourseWithOrgData } from '@cio/db/queries/course';
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
+import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
export interface PaymentRequestData {
courseId: string;
@@ -47,6 +48,15 @@ export async function createPaymentRequest(data: PaymentRequestData) {
const teacherEmail = teacherResult[0].email;
+ const dashboardOrg = {
+ siteName: course.orgSiteName,
+ customDomain: course.orgCustomDomain,
+ isCustomDomainVerified: course.orgIsCustomDomainVerified
+ };
+ const dashboardBaseUrl = getDashboardBaseUrl(dashboardOrg);
+ const courseUrl = `${dashboardBaseUrl}/courses/${data.courseId}`;
+ const autoEnrollUrl = `${dashboardBaseUrl}/courses/${data.courseId}/people?grantAccess=${encodeURIComponent(data.studentEmail)}`;
+
try {
await enqueueTransactionalEmail('teacherStudentBuyRequest', {
to: teacherEmail,
@@ -54,6 +64,8 @@ export async function createPaymentRequest(data: PaymentRequestData) {
courseName,
studentEmail: data.studentEmail,
studentFullname: data.studentFullname,
+ courseUrl,
+ autoEnrollUrl,
branding
},
from: buildEmailFromName('ClassroomIO'),
diff --git a/apps/dashboard/src/lib/features/course/components/people/grant-access-modal.svelte b/apps/dashboard/src/lib/features/course/components/people/grant-access-modal.svelte
new file mode 100644
index 00000000..6fa366e2
--- /dev/null
+++ b/apps/dashboard/src/lib/features/course/components/people/grant-access-modal.svelte
@@ -0,0 +1,91 @@
+
+
+ {
+ if (!open) closeModal();
+ }}
+>
+
+
+ {$t('course.navItem.people.grant_access_modal.title')}
+
+ {$t('course.navItem.people.grant_access_modal.description', { email: studentEmail })}
+
+
+
+ {#if $isStudentLimitReached}
+
+ {$t('course.navItem.people.invite_modal.student_limit_reached')}
+
+ {/if}
+
+
+
+
+
+
+
diff --git a/apps/dashboard/src/lib/features/course/pages/people.svelte b/apps/dashboard/src/lib/features/course/pages/people.svelte
index 72dea963..01df058e 100644
--- a/apps/dashboard/src/lib/features/course/pages/people.svelte
+++ b/apps/dashboard/src/lib/features/course/pages/people.svelte
@@ -11,6 +11,7 @@
import * as Avatar from '@cio/ui/base/avatar';
import { ComingSoon, RoleBasedSecurity, UpgradeBanner } from '$features/ui';
import InvitationModal from '$features/course/components/people/invitation-modal.svelte';
+ import GrantAccessModal from '$features/course/components/people/grant-access-modal.svelte';
import DeleteConfirmation from '$features/course/components/people/delete-confirmation.svelte';
import { isStudentLimitReached } from '$lib/utils/store/org';
@@ -101,6 +102,7 @@
+
{#if $isStudentLimitReached}
{$t('course.navItem.people.invite_modal.student_limit_reached')}
diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json
index a527097c..2a7e489e 100644
--- a/apps/dashboard/src/lib/utils/translations/da.json
+++ b/apps/dashboard/src/lib/utils/translations/da.json
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
+ "grant_access_modal": {
+ "title": "Giv studerende adgang",
+ "description": "Giv {email} adgang til dette kursus. Vi sender en invitationsmail, så vedkommende kan tilslutte sig din organisation og tilmelde sig kurset.",
+ "cancel": "Annuller",
+ "send_invite": "Send invitationen"
+ },
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json
index efeee7ef..7fa00a98 100644
--- a/apps/dashboard/src/lib/utils/translations/de.json
+++ b/apps/dashboard/src/lib/utils/translations/de.json
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
+ "grant_access_modal": {
+ "title": "Schülerzugang gewähren",
+ "description": "Gewähren Sie {email} Zugang zu diesem Kurs. Wir senden eine Einladungs-E-Mail, damit die Person Ihrer Organisation beitreten und in diesem Kurs eingeschrieben werden kann.",
+ "cancel": "Abbrechen",
+ "send_invite": "Einladung senden"
+ },
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json
index e2db9225..c7011c22 100644
--- a/apps/dashboard/src/lib/utils/translations/en.json
+++ b/apps/dashboard/src/lib/utils/translations/en.json
@@ -3230,6 +3230,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
+ "grant_access_modal": {
+ "title": "Give student access",
+ "description": "Give {email} access to this course. We'll send them an invite email to join your organization and enroll in this course.",
+ "cancel": "Cancel",
+ "send_invite": "Send invite"
+ },
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json
index 5b566801..612b2737 100644
--- a/apps/dashboard/src/lib/utils/translations/es.json
+++ b/apps/dashboard/src/lib/utils/translations/es.json
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
+ "grant_access_modal": {
+ "title": "Dar acceso al estudiante",
+ "description": "Da acceso a {email} a este curso. Le enviaremos un correo de invitación para unirse a tu organización e inscribirse en este curso.",
+ "cancel": "Cancelar",
+ "send_invite": "Enviar invitación"
+ },
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json
index abbafcd4..105484e1 100644
--- a/apps/dashboard/src/lib/utils/translations/fr.json
+++ b/apps/dashboard/src/lib/utils/translations/fr.json
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
+ "grant_access_modal": {
+ "title": "Accorder l'accès à l'étudiant",
+ "description": "Accordez à {email} l'accès à ce cours. Nous lui enverrons un e-mail d'invitation pour rejoindre votre organisation et s'inscrire à ce cours.",
+ "cancel": "Annuler",
+ "send_invite": "Envoyer l'invitation"
+ },
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json
index 7e4be06f..db086fbd 100644
--- a/apps/dashboard/src/lib/utils/translations/hi.json
+++ b/apps/dashboard/src/lib/utils/translations/hi.json
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
+ "grant_access_modal": {
+ "title": "छात्र को एक्सेस दें",
+ "description": "{email} को इस कोर्स का एक्सेस दें। हम उन्हें आपके संगठन में शामिल होने और इस कोर्स में नामांकन के लिए आमंत्रण ईमेल भेजेंगे।",
+ "cancel": "रद्द करें",
+ "send_invite": "निमंत्रण भेजें"
+ },
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json
index e718ecf9..e43fa495 100644
--- a/apps/dashboard/src/lib/utils/translations/pl.json
+++ b/apps/dashboard/src/lib/utils/translations/pl.json
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
+ "grant_access_modal": {
+ "title": "Udostępnij dostęp studentowi",
+ "description": "Przyznaj {email} dostęp do tego kursu. Wyślemy zaproszenie e-mailem, aby dołączył do Twojej organizacji i zapisał się na ten kurs.",
+ "cancel": "Anuluj",
+ "send_invite": "Wyślij zaproszenie"
+ },
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json
index 01036a1b..78acff4c 100644
--- a/apps/dashboard/src/lib/utils/translations/pt.json
+++ b/apps/dashboard/src/lib/utils/translations/pt.json
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
+ "grant_access_modal": {
+ "title": "Conceder acesso ao aluno",
+ "description": "Conceda acesso a {email} a este curso. Enviaremos um e-mail de convite para que a pessoa entre na sua organização e se inscreva neste curso.",
+ "cancel": "Cancelar",
+ "send_invite": "Enviar convite"
+ },
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json
index a6a7f56d..91df12ad 100644
--- a/apps/dashboard/src/lib/utils/translations/ru.json
+++ b/apps/dashboard/src/lib/utils/translations/ru.json
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
+ "grant_access_modal": {
+ "title": "Предоставить доступ студенту",
+ "description": "Предоставьте {email} доступ к этому курсу. Мы отправим приглашение по электронной почте, чтобы присоединиться к вашей организации и записаться на курс.",
+ "cancel": "Отмена",
+ "send_invite": "Отправить приглашение"
+ },
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json
index 3cedeee2..2c2a0e34 100644
--- a/apps/dashboard/src/lib/utils/translations/vi.json
+++ b/apps/dashboard/src/lib/utils/translations/vi.json
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
+ "grant_access_modal": {
+ "title": "Cấp quyền truy cập cho học viên",
+ "description": "Cấp cho {email} quyền truy cập khóa học này. Chúng tôi sẽ gửi email mời để tham gia tổ chức của bạn và đăng ký khóa học.",
+ "cancel": "Hủy",
+ "send_invite": "Gửi lời mời"
+ },
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
diff --git a/packages/email/src/emails/teacher-student-buy-request.ts b/packages/email/src/emails/teacher-student-buy-request.ts
index a82202b5..fb5c192d 100644
--- a/packages/email/src/emails/teacher-student-buy-request.ts
+++ b/packages/email/src/emails/teacher-student-buy-request.ts
@@ -11,6 +11,8 @@ export const teacherStudentBuyRequestEmail = defineEmail({
courseName: z.string().min(1),
studentEmail: z.string().email(),
studentFullname: z.string().min(1),
+ courseUrl: z.url(),
+ autoEnrollUrl: z.url(),
branding: ZEmailBranding
}),
render: (fields) => {
@@ -22,6 +24,19 @@ export const teacherStudentBuyRequestEmail = defineEmail({
Name: ${fields.studentFullname}
Email: ${fields.studentEmail}