import { useCallback, useMemo, useState, type FormEvent } from 'react'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { AddonEnablePanel } from '../components/AddonEnablePanel'; import { FeaturePreviewSkeleton } from '../components/FeaturePreviewSkeleton'; import { PlanUpgradeOverlay } from '../components/PlanUpgradeOverlay'; import { PREMIUM_GATE_VIEWPORT_MIN_H, PremiumGatedSurface } from '../components/PremiumGatedSurface'; import { SIKSHYA_ADMIN_PAGE_FULL_WIDTH } from '../constants/shellLayout'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ButtonPrimary } from '../components/shared/buttons'; import { ListEmptyState } from '../components/shared/list/ListEmptyState'; import { ListPanel } from '../components/shared/list/ListPanel'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; import { useAsyncData } from '../hooks/useAsyncData'; import { useAddonEnabled } from '../hooks/useAddons'; import { appViewHref } from '../lib/appUrl'; import { getLicensing, isFeatureEnabled } from '../lib/licensing'; import type { SikshyaReactConfig } from '../types'; import { __ } from '../lib/i18n'; type WebhookEndpointRowV2 = { id: number; label?: string; event_key?: string; delivery_url: string; is_active: boolean; has_secret?: boolean; failure_streak?: number; last_status_code?: number | null; last_error?: string | null; last_success_at?: string | null; last_failure_at?: string | null; created_at?: string | null; }; type ApiKeyRow = { id: number; owner_user_id?: number; label: string; key_prefix: string; scopes_json?: string | null; expires_at?: string | null; revoked: number; last_used_at?: string | null; created_at: string; }; type OAuthAppRow = { id: number; owner_user_id: number; name: string; client_id: string; redirect_uris_json?: string | null; scopes_json?: string | null; revoked: number; created_at: string; }; const API_SCOPES: Array<{ value: string; title: string; hint: string }> = [ { value: 'catalog:read', title: __('Catalog (read)', 'sikshya'), hint: 'Read courses and course pages.' }, { value: 'users:read', title: __('Users (read)', 'sikshya'), hint: 'Read learner profiles (PII). Use carefully.' }, { value: 'enrollments:read', title: __('Enrollments (read)', 'sikshya'), hint: 'Read enrollment and progress status.' }, { value: 'enrollments:write', title: __('Enrollments (write)', 'sikshya'), hint: 'Enroll/unenroll users. High impact.' }, { value: 'commerce:read', title: __('Commerce (read)', 'sikshya'), hint: 'Read order data.' }, { value: 'commerce:write', title: __('Commerce (write)', 'sikshya'), hint: 'Create/modify orders and payments. Highest risk.' }, { value: 'learning:read', title: __('Learning (read)', 'sikshya'), hint: 'Read learning progress.' }, { value: 'learning:write', title: __('Learning (write)', 'sikshya'), hint: 'Write progress/completions. High impact.' }, ]; const WEBHOOK_EVENTS: Array<{ value: string; title: string; hint: string }> = [ { value: 'enrollment.created', title: __('Learner enrolled', 'sikshya'), hint: 'Runs the moment a learner is enrolled (paid checkout, manual add, or free signup). Perfect for adding contacts to a CRM list.', }, { value: 'enrollment.deleted', title: __('Learner unenrolled', 'sikshya'), hint: 'Runs when a learner is removed from a course (manual removal or access revoked). Useful for subscription cancellations and cleanup.', }, { value: 'order.fulfilled', title: __('Order completed', 'sikshya'), hint: 'Runs after a learner’s payment is confirmed and they are enrolled. Use this to notify your CRM or accounting tool.', }, { value: 'lesson.completed', title: __('Lesson finished', 'sikshya'), hint: 'Runs when a learner completes a lesson. Great for progress-based nudges or unlocking external content.', }, { value: 'quiz.completed', title: __('Quiz finished', 'sikshya'), hint: 'Runs when a learner completes a quiz. Use the payload score to trigger remediation or congratulations.', }, { value: 'assignment.submitted', title: __('Assignment submitted', 'sikshya'), hint: 'Runs when a learner submits an assignment. Perfect for Slack/Email notifications or helpdesk tickets.', }, { value: 'course.completed', title: __('Course finished', 'sikshya'), hint: 'Runs when a learner completes every required item in a course. Great for celebration emails or loyalty workflows.', }, { value: 'certificate.issued', title: __('Certificate created', 'sikshya'), hint: 'Runs when Sikshya saves a new certificate row. Ideal for badges, LinkedIn automations, or secure archives.', }, { value: 'drip.lesson_unlocked', title: __('Drip: lesson unlocked', 'sikshya'), hint: 'Runs when a lesson becomes available due to drip rules. Useful for “new lesson available” notifications.', }, { value: 'drip.course_unlocked', title: __('Drip: course unlocked', 'sikshya'), hint: 'Runs when a full course becomes available due to drip rules.', }, { value: 'review.submitted', title: __('Review submitted', 'sikshya'), hint: 'Runs when a learner submits a course review (before or after approval depending on your workflow).', }, { value: 'review.approved', title: __('Review approved', 'sikshya'), hint: 'Runs when an admin approves a review.', }, { value: 'review.rejected', title: __('Review rejected', 'sikshya'), hint: 'Runs when an admin rejects a review.', }, { value: 'course.rating_updated', title: __('Course rating updated', 'sikshya'), hint: 'Runs when course rating aggregates change (after review changes).', }, { value: '*', title: __('Everything (advanced)', 'sikshya'), hint: 'Sends all supported event types to the same URL. Only pick this if you understand how to separate events in Zapier/Make.', }, { value: 'webhook.test', title: __('Test event (manual)', 'sikshya'), hint: 'A manual test payload you can send from Sikshya to validate your Zap or receiver URL.', }, ]; async function copyText(label: string, text: string, onFail: (m: string) => void) { try { await navigator.clipboard.writeText(text); } catch { try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } catch { onFail(`Could not copy automatically. Please copy ${label} manually.`); } } } export function IntegrationsPage(props: { config: SikshyaReactConfig; title: string; embedded?: boolean }) { const { config, title, embedded } = props; const lic = getLicensing(config); const dialog = useSikshyaDialog(); const whFeature = isFeatureEnabled(config, 'webhooks') || isFeatureEnabled(config, 'zapier'); const keyFeature = isFeatureEnabled(config, 'public_api_keys'); const whAddon = useAddonEnabled('webhooks'); const zapierAddon = useAddonEnabled('zapier'); const keyAddon = useAddonEnabled('public_api_keys'); const webhooksOn = whFeature && (Boolean(whAddon.enabled) || Boolean(zapierAddon.enabled)); const keysOn = keyFeature && Boolean(keyAddon.enabled); const [whEvent, setWhEvent] = useState('order.fulfilled'); const [whUrl, setWhUrl] = useState(''); const [whSecret, setWhSecret] = useState(''); const [whBusy, setWhBusy] = useState(false); const toast = useTopRightToast(); const [keyLabel, setKeyLabel] = useState(''); const [keyScopes, setKeyScopes] = useState(['catalog:read']); const [keyExpiryDays, setKeyExpiryDays] = useState(90); const [keyBusy, setKeyBusy] = useState(false); const [freshKey, setFreshKey] = useState(null); const [pingToken, setPingToken] = useState(''); const [pingBusy, setPingBusy] = useState(false); const [pingResult, setPingResult] = useState(null); const [appName, setAppName] = useState(''); const [appRedirect, setAppRedirect] = useState(''); const [appScopes, setAppScopes] = useState(['catalog:read']); const [appBusy, setAppBusy] = useState(false); const [freshAppSecret, setFreshAppSecret] = useState<{ clientId: string; clientSecret: string } | null>(null); const whLoader = useCallback(async () => { if (!webhooksOn) { return { ok: true, items: [] as WebhookEndpointRowV2[] }; } return getSikshyaApi().get<{ ok?: boolean; items?: WebhookEndpointRowV2[] }>(SIKSHYA_ENDPOINTS.scale.webhooksV2Endpoints); }, [webhooksOn]); const deliveriesLoader = useCallback(async () => { if (!webhooksOn) { return { ok: true, items: [] as Array> }; } return getSikshyaApi().get<{ ok?: boolean; items?: Array> }>(SIKSHYA_ENDPOINTS.scale.webhooksV2Deliveries); }, [webhooksOn]); const keysLoader = useCallback(async () => { if (!keysOn) { return { ok: true, rows: [] as ApiKeyRow[] }; } return getSikshyaApi().get<{ ok?: boolean; rows?: ApiKeyRow[] }>(SIKSHYA_ENDPOINTS.scale.publicApiKeys); }, [keysOn]); const wh = useAsyncData(whLoader, [webhooksOn]); const deliveries = useAsyncData(deliveriesLoader, [webhooksOn]); const keys = useAsyncData(keysLoader, [keysOn]); const appsLoader = useCallback(async () => { if (!keysOn) { return { ok: true, rows: [] as OAuthAppRow[] }; } return getSikshyaApi().get<{ ok?: boolean; rows?: OAuthAppRow[] }>(SIKSHYA_ENDPOINTS.scale.publicApiApps); }, [keysOn]); const apps = useAsyncData(appsLoader, [keysOn]); const addonsHref = useMemo(() => appViewHref(config, 'addons'), [config]); const selectedEventHelp = useMemo( () => WEBHOOK_EVENTS.find((e) => e.value === whEvent)?.hint ?? '', [whEvent] ); const addWebhook = async (e: FormEvent) => { e.preventDefault(); toast.clear(); if (!whUrl.trim()) { toast.error(__('Missing URL', 'sikshya'), 'Please paste the URL Zapier, Make, or your developer gave you.'); return; } setWhBusy(true); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.scale.webhooksV2Endpoints, { label: '', events: [whEvent], delivery_url: whUrl.trim(), secret: whSecret.trim() || undefined, }); setWhUrl(''); setWhSecret(''); toast.success(__('Saved', 'sikshya'), 'Webhook saved. We will POST JSON to that address when the event happens.'); wh.refetch(); } catch (err) { toast.error(__('Save failed', 'sikshya'), err instanceof Error ? err.message : 'Could not save webhook.'); } finally { setWhBusy(false); } }; const removeWebhook = async (row: WebhookEndpointRowV2) => { const ok = await dialog.confirm({ title: __('Remove this webhook?', 'sikshya'), message: (

Stops sending {row.event_key || '*'} events to{' '} {row.delivery_url}. You can add it again later.

), confirmLabel: __('Remove webhook', 'sikshya'), variant: 'danger', }); if (!ok) { return; } try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.scale.webhooksV2Endpoint(row.id), { is_active: false }); wh.refetch(); } catch { await dialog.alert({ title: __('Something went wrong', 'sikshya'), message: __('We could not remove that webhook. Try again.', 'sikshya') }); } }; const testWebhook = async (row: WebhookEndpointRowV2) => { toast.clear(); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.scale.webhooksV2EndpointTest(row.id), {}); toast.success(__('Queued', 'sikshya'), 'Queued a test delivery. Check your receiver and the delivery log.'); } catch (err) { toast.error(__('Queue failed', 'sikshya'), err instanceof Error ? err.message : 'Could not queue test delivery.'); } }; const createKey = async (e: FormEvent) => { e.preventDefault(); toast.clear(); setFreshKey(null); if (!keyLabel.trim()) { toast.error(__('Missing label', 'sikshya'), 'Give this key a short name so you remember what uses it (for example “Mobile app”).'); return; } setKeyBusy(true); try { const res = await getSikshyaApi().post<{ ok?: boolean; api_key?: string; message?: string }>( SIKSHYA_ENDPOINTS.scale.publicApiKeys, { label: keyLabel.trim(), scopes: keyScopes, expires_at: new Date(Date.now() + keyExpiryDays * 86400000).toISOString() } ); if (res.api_key) { setFreshKey(res.api_key); setKeyLabel(''); setKeyScopes(['catalog:read']); setKeyExpiryDays(90); toast.success(__('Created', 'sikshya'), res.message || 'Key created.'); keys.refetch(); } else { toast.error(__('Create failed', 'sikshya'), res.message || 'Unexpected response from server.'); } } catch (err) { toast.error(__('Create failed', 'sikshya'), err instanceof Error ? err.message : 'Could not create key.'); } finally { setKeyBusy(false); } }; const revokeKey = async (row: ApiKeyRow) => { const ok = await dialog.confirm({ title: __('Revoke this API key?', 'sikshya'), message: (

Anything still using {row.key_prefix}… will stop working immediately. This cannot be undone.

), confirmLabel: __('Revoke key', 'sikshya'), variant: 'danger', }); if (!ok) { return; } try { await getSikshyaApi().delete(SIKSHYA_ENDPOINTS.scale.publicApiKey(row.id)); keys.refetch(); } catch { await dialog.alert({ title: __('Something went wrong', 'sikshya'), message: __('We could not revoke that key. Try again.', 'sikshya') }); } }; const runPing = async () => { setPingResult(null); const token = pingToken.trim(); if (!token) { setPingResult(__('Paste an API key first, then tap “Test key”.', 'sikshya')); return; } setPingBusy(true); try { const body = await getSikshyaApi().get<{ ok?: boolean; site?: string; message?: string }>( SIKSHYA_ENDPOINTS.scale.publicApiPing, { headers: { Authorization: `Bearer ${token}`, }, } ); if (body && body.ok) { setPingResult(`Success — key works. Site name: ${body.site || '—'}`); } else { setPingResult(`Did not work: ${body?.message || 'Unknown error'}`); } } catch (err) { setPingResult(err instanceof Error ? err.message : 'Network error. Check your connection and try again.'); } finally { setPingBusy(false); } }; const createApp = async (e: FormEvent) => { e.preventDefault(); toast.clear(); setFreshAppSecret(null); if (!appName.trim()) { toast.error(__('Missing name', 'sikshya'), 'Give the app a name (for example “Mobile app”).'); return; } if (!appRedirect.trim()) { toast.error(__('Missing redirect URL', 'sikshya'), 'Add a redirect URL (must start with https://).'); return; } setAppBusy(true); try { const res = await getSikshyaApi().post<{ ok?: boolean; client_id?: string; client_secret?: string; message?: string }>( SIKSHYA_ENDPOINTS.scale.publicApiApps, { name: appName.trim(), redirect_uris: [appRedirect.trim()], scopes: appScopes } ); if (res.client_id && res.client_secret) { setFreshAppSecret({ clientId: res.client_id, clientSecret: res.client_secret }); setAppName(''); setAppRedirect(''); setAppScopes(['catalog:read']); toast.success(__('Created', 'sikshya'), res.message || 'App created.'); apps.refetch(); } else { toast.error(__('Create failed', 'sikshya'), res.message || 'Unexpected response from server.'); } } catch (err) { toast.error(__('Create failed', 'sikshya'), err instanceof Error ? err.message : 'Could not create app.'); } finally { setAppBusy(false); } }; const revokeApp = async (row: OAuthAppRow) => { const ok = await dialog.confirm({ title: __('Revoke this OAuth app?', 'sikshya'), message: (

Any tokens issued to {row.client_id} may stop working. You can create a new app later.

), confirmLabel: __('Revoke app', 'sikshya'), variant: 'danger', }); if (!ok) return; try { await getSikshyaApi().delete(SIKSHYA_ENDPOINTS.scale.publicApiApp(row.id)); apps.refetch(); } catch { await dialog.alert({ title: __('Something went wrong', 'sikshya'), message: __('We could not revoke that app. Try again.', 'sikshya') }); } }; return (

{__('Start here', 'sikshya')}

  1. {__('Webhooks', 'sikshya')} let other websites react when something happens in Sikshya (for example “new order”). You only paste a URL your automation tool gives you.
  2. {__('API keys', 'sikshya')} let trusted apps read a small “ping” endpoint today; you can expand usage later with your developer.
  3. Both features live under{' '} Addons — turn them on first, then come back to this page.
{/* Webhooks — full width so premium overlays span the content column */}

{__('Webhooks', 'sikshya')}

Send a tiny JSON message to another service when important learning events happen.

{!whFeature ? (
) : !webhooksOn ? (
{ // Prefer enabling Webhooks (generic) but allow Zapier-only customers to proceed. if (whAddon.licenseOk) await whAddon.enable(); else await zapierAddon.enable(); }} upgradeUrl={lic?.upgradeUrl} error={whAddon.error || zapierAddon.error} />
) : ( <>

{selectedEventHelp}

setWhUrl(e.target.value)} placeholder="https://hooks.zapier.com/…" className="mt-1.5 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500" />

Paste the exact URL from Zapier, Make.com, or your developer. We only accept HTTPS in production sites.

setWhSecret(e.target.value)} placeholder={__('Same secret you configure in Zapier', 'sikshya')} className="mt-1.5 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500" />

If you set this, we add an {__('X-Sikshya-Signature', 'sikshya')} header so the receiver can trust the payload.

{whBusy ? __('Saving…', 'sikshya') : __('Save webhook', 'sikshya')}

{__('Active webhooks', 'sikshya')}

{wh.error ? (
wh.refetch()} />
) : ( {wh.loading ? (
{__('Loading…', 'sikshya')}
) : (wh.data?.items?.length ?? 0) === 0 ? ( ) : (
    {(wh.data?.items ?? []).map((r) => (
  • {r.event_key || '*'}
    {r.delivery_url}
    {r.last_error ? (
    Last error: {r.last_error}
    ) : null}
  • ))}
)}
)}

{__('Recent deliveries', 'sikshya')}

{deliveries.error ? (
deliveries.refetch()} />
) : ( {deliveries.loading ? (
{__('Loading…', 'sikshya')}
) : (deliveries.data?.items?.length ?? 0) === 0 ? ( ) : (
{(deliveries.data?.items ?? []).map((r: any) => ( ))}
{__('Status', 'sikshya')} {__('Event', 'sikshya')} {__('Endpoint', 'sikshya')} {__('Attempts', 'sikshya')} {__('Created', 'sikshya')}
{String(r.status || '')} {String(r.event_key || '')} {String(r.endpoint_id || '')} {String(r.attempt_count || 0) + '/' + String(r.max_attempts || 0)} {String(r.created_at || '')}
)}
)}
)}
{/* API keys — full width */}

{__('API keys', 'sikshya')}

Keys let external apps prove they are allowed to talk to your site. Treat them like passwords.

{!keyFeature ? (
) : !keyAddon.enabled ? (
keyAddon.enable()} upgradeUrl={lic?.upgradeUrl} error={keyAddon.error} />
) : ( <> {freshKey ? (

{__('Copy this key now', 'sikshya')}

For your security we never show the full key again. If you lose it, revoke the old key and create a new one.

{freshKey} void copyText('the API key', freshKey, async (m) => { await dialog.alert({ title: __('Copy manually', 'sikshya'), message: m }); }) } > Copy key
) : null}
setKeyLabel(e.target.value)} placeholder={__('e.g. Zapier read-only', 'sikshya')} className="mt-1.5 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500" />
{API_SCOPES.map((s) => { const checked = keyScopes.includes(s.value); return ( ); })}

Tip: start with read-only scopes. You can always revoke and create a stricter key later.

setKeyExpiryDays(Math.max(1, Math.min(3650, Number(e.target.value) || 90)))} className="mt-1.5 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500" />
{keyBusy ? __('Creating…', 'sikshya') : __('Create new key', 'sikshya')}

{__('Test a key (optional)', 'sikshya')}

Paste a key and tap test — we call a safe “ping” endpoint so you know the format is correct before you plug it into another system.

setPingToken(e.target.value)} placeholder={__('sk_live_…', 'sikshya')} className="w-full flex-1 rounded-xl border border-slate-200 px-3 py-2 font-mono text-xs dark:border-slate-700 dark:bg-slate-950" /> void runPing()}> {pingBusy ? __('Testing…', 'sikshya') : __('Test key', 'sikshya')}
{pingResult ?

{pingResult}

: null}

{__('Existing keys', 'sikshya')}

{keys.error ? (
keys.refetch()} />
) : ( {keys.loading ? (
{__('Loading…', 'sikshya')}
) : (keys.data?.rows?.length ?? 0) === 0 ? ( ) : (
    {(keys.data?.rows ?? []).map((r) => (
  • {r.label}
    {r.key_prefix}… · {r.revoked ? __('revoked', 'sikshya') : __('active', 'sikshya')}
    {!r.revoked ? ( ) : null}
  • ))}
)}
)}

{__('OAuth apps', 'sikshya')}

Use OAuth when you need user consent and per-user access tokens (recommended for third-party apps).

{apps.error ? (
apps.refetch()} />
) : ( {freshAppSecret ? (

{__('Store these credentials now', 'sikshya')}

We only show the client secret once.

{__('Client ID', 'sikshya')} {freshAppSecret.clientId}
{__('Client secret', 'sikshya')} {freshAppSecret.clientSecret}
void copyText('the client secret', freshAppSecret.clientSecret, async (m) => { await dialog.alert({ title: __('Copy manually', 'sikshya'), message: m }); }) } > Copy client secret
) : null}
setAppName(e.target.value)} placeholder={__('e.g. Mobile app', 'sikshya')} className="mt-1.5 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500" />
setAppRedirect(e.target.value)} placeholder="https://yourapp.com/oauth/callback" className="mt-1.5 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500" />
{API_SCOPES.map((s) => { const checked = appScopes.includes(s.value); return ( ); })}
{appBusy ? __('Creating…', 'sikshya') : __('Create OAuth app', 'sikshya')}
{apps.loading ? (
{__('Loading…', 'sikshya')}
) : (apps.data?.rows?.length ?? 0) === 0 ? ( ) : (
    {(apps.data?.rows ?? []).map((r) => (
  • {r.name}
    {r.client_id}
    {r.revoked ? __('revoked', 'sikshya') : __('active', 'sikshya')}
    {!r.revoked ? ( ) : null}
  • ))}
)}
)}
)}
); }