Merge pull request #486 from learnhouse/feat/custom-js-scripts

Custom organizations Scripts
This commit is contained in:
Badr B. 2025-06-08 19:35:46 +02:00 committed by GitHub
commit 93d0e2a104
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 582 additions and 8 deletions

View file

@ -0,0 +1,31 @@
"""Org Scripts
Revision ID: eb10d15465b3
Revises: a5afa69dd917
Create Date: 2025-06-08 18:12:18.853988
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa # noqa: F401
import sqlmodel # noqa: F401
# revision identifiers, used by Alembic.
revision: str = 'eb10d15465b3'
down_revision: Union[str, None] = 'a5afa69dd917'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('organization', sa.Column('scripts', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('organization', 'scripts')
# ### end Alembic commands ###

View file

@ -12,6 +12,7 @@ class OrganizationBase(SQLModel):
about: Optional[str]
socials: Optional[dict] = Field(default={}, sa_column=Column(JSON))
links: Optional[dict] = Field(default={}, sa_column=Column(JSON))
scripts: Optional[dict] = Field(default={}, sa_column=Column(JSON))
logo_image: Optional[str]
thumbnail_image: Optional[str]
previews: Optional[dict] = Field(default={}, sa_column=Column(JSON))
@ -38,6 +39,7 @@ class OrganizationUpdate(SQLModel):
about: Optional[str] = None
socials: Optional[dict] = None
links: Optional[dict] = None
scripts: Optional[dict] = None
logo_image: Optional[str] = None
thumbnail_image: Optional[str] = None
previews: Optional[dict] = None

View file

@ -24,7 +24,7 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
// Get Org context information
const course_meta = await getCourseMetadata(
params.courseid,
{ revalidate: 30, tags: ['courses'] },
{ revalidate: 0, tags: ['courses'] },
access_token ? access_token : null
)
@ -41,7 +41,7 @@ const EditActivity = async (params: any) => {
const courseid = (await params.params).courseid
const courseInfo = await getCourseMetadata(
courseid,
{ revalidate: 30, tags: ['courses'] },
{ revalidate: 0, tags: ['courses'] },
access_token ? access_token : null
)
const activity = await getActivityWithAuthHeader(

View file

@ -21,7 +21,7 @@ type Session = {
async function fetchCourseMetadata(courseuuid: string, access_token: string | null | undefined) {
return await getCourseMetadata(
courseuuid,
{ revalidate: 30, tags: ['courses'] },
{ revalidate: 0, tags: ['courses'] },
access_token || null
)
}

View file

@ -24,7 +24,7 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
})
const course_meta = await getCourseMetadata(
params.courseuuid,
{ revalidate: 30, tags: ['courses'] },
{ revalidate: 0, tags: ['courses'] },
access_token ? access_token : null
)
@ -72,7 +72,7 @@ const CoursePage = async (params: any) => {
// Fetch course metadata once
const course_meta = await getCourseMetadata(
params.params.courseuuid,
{ revalidate: 30, tags: ['courses'] },
{ revalidate: 0, tags: ['courses'] },
access_token ? access_token : null
)

View file

@ -1,7 +1,7 @@
'use client'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { getUriWithOrg } from '@services/config/config'
import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon, LayoutDashboardIcon } from 'lucide-react'
import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon, LayoutDashboardIcon, CodeIcon } from 'lucide-react'
import Link from 'next/link'
import React, { useEffect, use } from 'react';
import { motion } from 'framer-motion'
@ -9,6 +9,7 @@ import OrgEditGeneral from '@components/Dashboard/Pages/Org/OrgEditGeneral/OrgEd
import OrgEditImages from '@components/Dashboard/Pages/Org/OrgEditImages/OrgEditImages'
import OrgEditSocials from '@components/Dashboard/Pages/Org/OrgEditSocials/OrgEditSocials'
import OrgEditLanding from '@components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding'
import OrgEditOther from '@components/Dashboard/Pages/Org/OrgEditOther/OrgEditOther'
export type OrgParams = {
subpage: string
@ -26,6 +27,7 @@ const SETTING_TABS: TabItem[] = [
{ id: 'landing', label: 'Landing Page', icon: LayoutDashboardIcon },
{ id: 'previews', label: 'Images & Previews', icon: ImageIcon },
{ id: 'socials', label: 'Socials', icon: Share2Icon },
{ id: 'other', label: 'Other', icon: CodeIcon },
]
function TabLink({ tab, isActive, orgslug }: {
@ -67,6 +69,9 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
} else if (params.subpage == 'landing') {
setH1Label('Landing Page')
setH2Label('Customize your organization landing page')
} else if (params.subpage == 'other') {
setH1Label('Other')
setH2Label('Manage additional organization settings')
}
}
@ -111,6 +116,7 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
{params.subpage == 'previews' ? <OrgEditImages /> : ''}
{params.subpage == 'socials' ? <OrgEditSocials /> : ''}
{params.subpage == 'landing' ? <OrgEditLanding /> : ''}
{params.subpage == 'other' ? <OrgEditOther /> : ''}
</motion.div>
</div>
)

View file

@ -5,6 +5,7 @@ import NextTopLoader from 'nextjs-toploader';
import Toast from '@components/Objects/StyledElements/Toast/Toast'
import '@styles/globals.css'
import Onboarding from '@components/Objects/Onboarding/Onboarding';
import Footer from "@components/Footer/Footer";
export default function RootLayout(
props: {
@ -25,6 +26,7 @@ export default function RootLayout(
<Toast />
<Onboarding />
{children}
<Footer />
</OrgProvider>
</div>
)

View file

@ -0,0 +1,282 @@
'use client'
import React from 'react'
import { Form, Formik } from 'formik'
import * as Yup from 'yup'
import { updateOrganization } from '@services/settings/org'
import { revalidateTags } from '@services/utils/ts/requests'
import { useRouter } from 'next/navigation'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { toast } from 'react-hot-toast'
import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import { Textarea } from "@components/ui/textarea"
import { Code2, Plus, Trash2, PencilLine, AlertTriangle } from "lucide-react"
import { mutate } from 'swr'
import { getAPIUrl } from '@services/config/config'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
interface Script {
name: string
content: string
}
interface OrganizationScripts {
scripts: Script[]
}
const validationSchema = Yup.object().shape({
name: Yup.string().required('Script name is required'),
content: Yup.string().required('Script content is required')
})
const OrgEditOther: React.FC = () => {
const router = useRouter()
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const org = useOrg() as any
const [selectedView, setSelectedView] = React.useState<'list' | 'edit'>('list')
const [scripts, setScripts] = React.useState<Script[]>([])
const [currentScript, setCurrentScript] = React.useState<Script | null>(null)
// Initialize scripts from org
React.useEffect(() => {
if (org?.scripts?.scripts) {
setScripts(Array.isArray(org.scripts.scripts) ? org.scripts.scripts : [])
} else {
setScripts([])
}
}, [org])
const updateOrg = async (values: Script) => {
const loadingToast = toast.loading('Updating organization...')
try {
let updatedScripts: Script[]
if (currentScript) {
// Edit existing script
updatedScripts = scripts.map(script =>
script.name === currentScript.name ? values : script
)
} else {
// Add new script
updatedScripts = [...scripts, values]
}
// Create a new organization object with scripts array wrapped in an object
const updateData = {
id: org.id,
scripts: {
scripts: updatedScripts
}
}
await updateOrganization(org.id, updateData, access_token)
await revalidateTags(['organizations'], org.slug)
mutate(`${getAPIUrl()}orgs/slug/${org.slug}`)
setScripts(updatedScripts)
setSelectedView('list')
setCurrentScript(null)
toast.success('Script saved successfully', { id: loadingToast })
} catch (err) {
console.error('Error updating organization:', err)
toast.error('Failed to save script', { id: loadingToast })
}
}
const deleteScript = async (scriptToDelete: Script) => {
const loadingToast = toast.loading('Deleting script...')
try {
const updatedScripts = scripts.filter(script => script.name !== scriptToDelete.name)
// Create a new organization object with scripts array wrapped in an object
const updateData = {
id: org.id,
scripts: {
scripts: updatedScripts
}
}
await updateOrganization(org.id, updateData, access_token)
await revalidateTags(['organizations'], org.slug)
mutate(`${getAPIUrl()}orgs/slug/${org.slug}`)
setScripts(updatedScripts)
toast.success('Script deleted successfully', { id: loadingToast })
} catch (err) {
console.error('Error deleting script:', err)
toast.error('Failed to delete script', { id: loadingToast })
}
}
return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow">
<div className="pt-0.5">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md">
<div className="flex items-center justify-between">
<div>
<h1 className="font-bold text-xl text-gray-800 flex items-center space-x-2">
<Code2 className="h-5 w-5" />
<span>Scripts</span>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger>
<AlertTriangle className="h-4 w-4 text-orange-500 hover:text-orange-600 transition-colors" />
</TooltipTrigger>
<TooltipContent
className="max-w-[400px] bg-orange-50 border-orange-100 text-orange-900 [&>p]:text-orange-800 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1"
sideOffset={8}
>
<p className="p-2 leading-relaxed">For your organization's safety, please ensure you trust and understand any scripts before adding them. Scripts can interact with your organization's pages.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</h1>
<h2 className="text-gray-500 text-md">
Add custom JavaScript scripts to your organization
</h2>
</div>
{selectedView === 'list' && (
<Button
onClick={() => {
setCurrentScript(null)
setSelectedView('edit')
}}
className="bg-black text-white hover:bg-black/90"
>
<Plus className="h-4 w-4 mr-2" />
Add Script
</Button>
)}
</div>
</div>
</div>
<div className="p-4 pt-1">
{selectedView === 'list' ? (
<div className="space-y-4">
{(!scripts || scripts.length === 0) ? (
<div className="text-center py-8 px-4 text-gray-500 bg-gray-50/50 rounded-lg border border-dashed border-gray-200">
<Code2 className="h-8 w-8 mx-auto mb-2 text-gray-400" />
<p className="text-sm font-medium">No scripts added yet</p>
<p className="text-xs text-gray-400 mt-1">Add your first script to get started</p>
</div>
) : (
scripts.map((script, index) => (
<div
key={index}
className="group p-4 rounded-lg bg-gray-50/50 hover:bg-gray-100/80 transition-colors duration-150 border border-gray-200"
>
<div className="flex items-start justify-between">
<div className="space-y-2 min-w-0 flex-1">
<div className="flex items-baseline space-x-2">
<h4 className="text-sm font-medium text-gray-800 truncate">{script.name}</h4>
</div>
<pre className="text-sm text-gray-600 font-mono bg-white/80 p-2 rounded border border-gray-200 overflow-x-auto">
{script.content.length > 100
? script.content.substring(0, 100) + '...'
: script.content}
</pre>
</div>
<div className="ml-4 flex space-x-2">
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50"
onClick={() => {
setCurrentScript(script)
setSelectedView('edit')
}}
>
<PencilLine className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
onClick={() => deleteScript(script)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))
)}
</div>
) : (
<Formik
initialValues={currentScript || { name: '', content: '' }}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => {
setSubmitting(false)
updateOrg(values)
}}
>
{({ values, handleChange, handleSubmit, errors, touched, isSubmitting }) => (
<Form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<Label htmlFor="name">Script Name</Label>
<input
type="text"
id="name"
name="name"
value={values.name}
onChange={handleChange}
className="mt-1 w-full px-3 py-2 border rounded-md"
placeholder="Enter script name"
/>
{touched.name && errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
</div>
<div>
<Label htmlFor="content">Script Content</Label>
<Textarea
id="content"
name="content"
value={values.content}
onChange={handleChange}
className="mt-1 font-mono"
placeholder="Enter JavaScript code"
rows={10}
/>
{touched.content && errors.content && (
<p className="text-red-500 text-sm mt-1">{errors.content}</p>
)}
</div>
<div className="flex justify-end space-x-3">
<Button
type="button"
variant="outline"
onClick={() => {
setSelectedView('list')
setCurrentScript(null)
}}
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? 'Saving...' : 'Save Script'}
</Button>
</div>
</div>
</Form>
)}
</Formik>
)}
</div>
</div>
)
}
export default OrgEditOther

View file

@ -0,0 +1,20 @@
'use client'
import React from 'react'
import OrgScripts from '@/components/OrgScripts/OrgScripts'
import { usePathname } from 'next/navigation'
import { OrgProvider } from '@/components/Contexts/OrgContext'
const Footer: React.FC = () => {
const pathname = usePathname()
const isDashboard = pathname?.startsWith('/dashboard')
// Don't run scripts in dashboard pages
if (isDashboard) {
return null
}
return <OrgScripts />
}
export default Footer

View file

@ -0,0 +1,202 @@
'use client'
import React, { useEffect } from 'react'
import { useOrg } from '@/components/Contexts/OrgContext'
import DOMPurify from 'dompurify'
const OrgScripts: React.FC = () => {
const org = useOrg() as any
// Function to cleanup existing scripts
const cleanupExistingScript = (scriptId: string) => {
const existingScript = document.getElementById(scriptId)
if (existingScript) {
const parent = existingScript.parentNode
if (parent) {
let node = existingScript.previousSibling
while (node && node.nodeType === Node.COMMENT_NODE) {
const prevNode = node.previousSibling
parent.removeChild(node)
node = prevNode
}
node = existingScript.nextSibling
while (node && node.nodeType === Node.COMMENT_NODE) {
const nextNode = node.nextSibling
parent.removeChild(node)
node = nextNode
}
parent.removeChild(existingScript)
}
}
}
// Function to check if script is already loaded
const isScriptLoaded = (scriptName: string): boolean => {
const scripts = document.querySelectorAll(`script[data-script-name="${scriptName}"]`)
return scripts.length > 0
}
// Function to sanitize script content using DOMPurify
const sanitizeScriptContent = (content: string): string => {
if (typeof window === 'undefined') {
return content;
}
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
if (node.nodeName === 'SCRIPT') {
node.setAttribute('type', 'text/javascript');
}
});
const purifyConfig = {
ALLOWED_TAGS: ['script'],
ALLOWED_ATTR: [
'src', 'async', 'defer', 'crossorigin',
'integrity', 'type', 'nonce', 'id',
'data-*', 'referrerpolicy'
],
ADD_TAGS: ['script'],
WHOLE_DOCUMENT: false,
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
FORCE_BODY: true
}
if (content.trim().toLowerCase().startsWith('<script')) {
return DOMPurify.sanitize(content, purifyConfig)
} else {
return DOMPurify.sanitize(content, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
WHOLE_DOCUMENT: false
})
}
}
// Function to safely load and execute a script
const loadScript = (scriptContent: string, scriptName: string) => {
try {
if (isScriptLoaded(scriptName) || !scriptContent.trim()) {
return
}
const safeScriptId = `learnhouse-org-script-${scriptName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Math.random().toString(36).substr(2, 9)}`
cleanupExistingScript(safeScriptId)
if (scriptContent.trim().toLowerCase().startsWith('<script')) {
const sanitizedHtml = sanitizeScriptContent(scriptContent.trim())
const div = document.createElement('div')
div.innerHTML = sanitizedHtml
const scriptTag = div.querySelector('script')
if (!scriptTag) {
return
}
const scriptElement = document.createElement('script')
Array.from(scriptTag.attributes).forEach(attr => {
scriptElement.setAttribute(attr.name, attr.value)
})
if (scriptTag.src) {
try {
new URL(scriptTag.src)
scriptElement.async = true
scriptElement.onload = () => {
scriptElement.dataset.loaded = 'true'
}
scriptElement.onerror = (error) => {
console.error(`Failed to load external script "${scriptName}":`, error)
cleanupExistingScript(safeScriptId)
}
} catch (error) {
console.error(`Invalid script URL in "${scriptName}":`, error)
return
}
} else {
const sanitizedContent = sanitizeScriptContent(scriptTag.textContent || '')
scriptElement.textContent = `
/* LearnHouse Organization Script - ${scriptName} */
try {
(function() {
'use strict';
${sanitizedContent}
})();
} catch (error) {
console.error("Script error in ${scriptName}:", error);
}
`
}
scriptElement.id = safeScriptId
scriptElement.dataset.scriptName = scriptName
scriptElement.dataset.loadTime = new Date().toISOString()
scriptElement.dataset.type = scriptTag.src ? 'external' : 'inline'
scriptElement.dataset.orgId = org?.id
scriptElement.dataset.orgSlug = org?.slug
const comment = document.createComment(` LearnHouse Organization Script - ${scriptName} (${safeScriptId}) `)
document.body.appendChild(comment)
document.body.appendChild(scriptElement)
} else {
const scriptElement = document.createElement('script')
scriptElement.type = 'text/javascript'
const sanitizedContent = sanitizeScriptContent(scriptContent)
scriptElement.textContent = `
/* LearnHouse Organization Script - ${scriptName} */
try {
(function() {
'use strict';
${sanitizedContent}
})();
} catch (error) {
console.error("Script error in ${scriptName}:", error)
}
`
scriptElement.id = safeScriptId
scriptElement.dataset.scriptName = scriptName
scriptElement.dataset.loadTime = new Date().toISOString()
scriptElement.dataset.type = 'raw'
scriptElement.dataset.orgId = org?.id
scriptElement.dataset.orgSlug = org?.slug
const comment = document.createComment(` LearnHouse Organization Script - ${scriptName} (${safeScriptId}) `)
document.body.appendChild(comment)
document.body.appendChild(scriptElement)
}
} catch (error) {
console.error(`Failed to load script ${scriptName}:`, error)
}
}
useEffect(() => {
if (!org || !org?.scripts?.scripts || !Array.isArray(org.scripts.scripts)) {
return
}
const loadedScripts = new Map()
org.scripts.scripts.forEach((script: { content: string, name: string }, index: number) => {
const scriptName = script.name || `Script ${index + 1}`
if (!loadedScripts.has(scriptName) && script.content) {
loadedScripts.set(scriptName, true)
loadScript(script.content, scriptName)
}
})
return () => {
const scripts = document.querySelectorAll('script[id^="learnhouse-org-script-"]')
scripts.forEach(script => {
cleanupExistingScript(script.id)
})
}
}, [org])
return null
}
export default OrgScripts

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -1,5 +1,5 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))