Merge pull request #400 from learnhouse/fix/misc-bugs

Fix Misc Bugs & Issues
This commit is contained in:
Badr B. 2024-11-29 00:18:58 +01:00 committed by GitHub
commit 8c7b4a3f7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 214 additions and 92 deletions

View file

@ -67,3 +67,33 @@ def migrate_v0_to_v1(v0_config):
}
return v1_config
def migrate_to_v1_1(v1_config):
# Start by copying the existing configuration
v1_1_config = v1_config.copy()
# Update the config version
v1_1_config["config_version"] = "1.1"
# Add the new 'cloud' object at the end
v1_1_config['cloud'] = {
"plan": "free",
"custom_domain": False
}
return v1_1_config
def migrate_to_v1_2(v1_1_config):
v1_2_config = v1_1_config.copy()
v1_2_config['config_version'] = '1.2'
# Enable payments for everyone
v1_2_config['features']['payments']['enabled'] = True
# Only delete stripe_key if it exists
if 'stripe_key' in v1_2_config['features']['payments']:
del v1_2_config['features']['payments']['stripe_key']
return v1_2_config

View file

@ -1,14 +0,0 @@
def migrate_to_v1_1(v1_config):
# Start by copying the existing configuration
v1_1_config = v1_config.copy()
# Update the config version
v1_1_config["config_version"] = "1.1"
# Add the new 'cloud' object at the end
v1_1_config['cloud'] = {
"plan": "free",
"custom_domain": False
}
return v1_1_config

View file

@ -1,8 +1,7 @@
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from config.config import get_learnhouse_config
from migrations.orgconfigs.v0tov1 import migrate_v0_to_v1
from migrations.orgconfigs.v1_1 import migrate_to_v1_1
from migrations.orgconfigs.orgconfigs_migrations import migrate_to_v1_1, migrate_to_v1_2, migrate_v0_to_v1
from src.core.events.database import get_db_session
from src.db.organization_config import OrganizationConfig
@ -51,4 +50,22 @@ async def migratev1_1(
db_session.add(orgConfig)
db_session.commit()
return {"message": "Migration successful"}
@router.post("/migrate_orgconfig_v1_to_v1.2")
async def migratev1_2(
db_session: Session = Depends(get_db_session),
):
"""
Migrate organization config from v0 to v1
"""
statement = select(OrganizationConfig)
result = db_session.exec(statement)
for orgConfig in result:
orgConfig.config = migrate_to_v1_2(orgConfig.config)
db_session.add(orgConfig)
db_session.commit()
return {"message": "Migration successful"}

View file

@ -16,7 +16,7 @@ from src.db.usergroup_resources import UserGroupResource
from src.db.usergroup_user import UserGroupUser
from src.db.organizations import Organization
from src.db.usergroups import UserGroup, UserGroupCreate, UserGroupRead, UserGroupUpdate
from src.db.users import AnonymousUser, PublicUser, User, UserRead
from src.db.users import AnonymousUser, InternalUser, PublicUser, User, UserRead
async def create_usergroup(
@ -275,7 +275,7 @@ async def delete_usergroup_by_id(
async def add_users_to_usergroup(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
current_user: PublicUser | AnonymousUser | InternalUser,
usergroup_id: int,
user_ids: str,
) -> str:
@ -486,10 +486,13 @@ async def remove_resources_from_usergroup(
async def rbac_check(
request: Request,
usergroup_uuid: str,
current_user: PublicUser | AnonymousUser,
current_user: PublicUser | AnonymousUser | InternalUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
if isinstance(current_user, InternalUser):
return True
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship(

View file

@ -21,6 +21,7 @@ from src.security.rbac.rbac import (
from src.db.organizations import Organization, OrganizationRead
from src.db.users import (
AnonymousUser,
InternalUser,
PublicUser,
User,
UserCreate,
@ -147,19 +148,21 @@ async def create_user_with_invite(
# Usage check
check_limits_with_usage("members", org_id, db_session)
user = await create_user(request, db_session, current_user, user_object, org_id)
# Check if invite code contains UserGroup
if inviteCode.usergroup_id:
if inviteCode.get("usergroup_id"):
# Add user to UserGroup
await add_users_to_usergroup(
request,
db_session,
current_user,
inviteCode.usergroup_id,
user_object.username,
InternalUser(id=0),
int(inviteCode.get("usergroup_id")), # Convert to int since usergroup_id is expected to be int
str(user.id),
)
user = await create_user(request, db_session, current_user, user_object, org_id)
increase_feature_usage("members", org_id, db_session)
return user

View file

@ -3,7 +3,7 @@ import learnhouseIcon from 'public/learnhouse_bigicon_1.png'
import Image from 'next/image'
import { getOrgLogoMediaDirectory } from '@services/media/media'
import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config'
import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import React, { useEffect } from 'react'
import { MailWarning, Ticket, UserPlus } from 'lucide-react'
@ -191,7 +191,7 @@ const NoTokenScreen = (props: any) => {
"Invite code is valid, you'll be redirected to the signup page in a few seconds"
)
setTimeout(() => {
router.push(`/signup?inviteCode=${inviteCode}&orgslug=${org.slug}`)
router.push(getUriWithoutOrg(`/signup?inviteCode=${inviteCode}&orgslug=${org.slug}`))
}, 2000)
} else {
toast.error('Invite code is invalid')

View file

@ -74,8 +74,12 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
const removeOption = (qIndex: number, oIndex: number) => {
const updatedQuestions = [...questions];
updatedQuestions[qIndex].options.splice(oIndex, 1);
setQuestions(updatedQuestions);
if (updatedQuestions[qIndex].options.length > 1) {
updatedQuestions[qIndex].options.splice(oIndex, 1);
setQuestions(updatedQuestions);
} else {
toast.error('Cannot delete the last option. At least one option is required.');
}
};
const addQuestion = () => {

View file

@ -51,7 +51,7 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
about: courseStructure?.about || '',
learnings: courseStructure?.learnings || '',
tags: courseStructure?.tags || '',
public: courseStructure?.public || '',
public: courseStructure?.public || false,
},
validate,
onSubmit: async values => {

View file

@ -1,7 +1,7 @@
import { useOrg } from '@components/Contexts/OrgContext'
import PageLoading from '@components/Objects/Loaders/PageLoading'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { getAPIUrl, getUriWithOrg } from '@services/config/config'
import { getAPIUrl, getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
import { swrFetcher } from '@services/utils/ts/requests'
import { Globe, Ticket, UserSquare, Users, X } from 'lucide-react'
import Link from 'next/link'
@ -173,14 +173,12 @@ function OrgAccess() {
<Link
className="outline bg-gray-50 text-gray-600 px-2 py-1 rounded-md outline-gray-300 outline-dashed outline-1"
target="_blank"
href={getUriWithOrg(
org?.slug,
`/signup?inviteCode=${invite.invite_code}`
href={getUriWithoutOrg(
`/signup?inviteCode=${invite.invite_code}&orgslug=${org.slug}`
)}
>
{getUriWithOrg(
org?.slug,
`/signup?inviteCode=${invite.invite_code}`
{getUriWithoutOrg(
`/signup?inviteCode=${invite.invite_code}&orgslug=${org.slug}`
)}
</Link>
</td>

View file

@ -163,7 +163,9 @@ function AIActionButton(props: {
})
await dispatchAIChatBot({ type: 'setIsWaitingForResponse' })
const response = await startActivityAIChatSession(
message, access_token
message,
access_token,
props.activity.activity_uuid
)
if (response.success == false) {
await dispatchAIChatBot({ type: 'setIsNoLongerWaitingForResponse' })

View file

@ -6,7 +6,7 @@ import { revalidateTags } from '@services/utils/ts/requests'
import { useRouter } from 'next/navigation'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useMediaQuery } from 'usehooks-ts'
import { getUriWithOrg } from '@services/config/config'
import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
import { getProductsByCourse } from '@services/payments/products'
import { LogIn, LogOut, ShoppingCart, AlertCircle } from 'lucide-react'
import Modal from '@components/Objects/StyledElements/Modal/Modal'
@ -126,7 +126,7 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
const handleCourseAction = async () => {
if (!session.data?.user) {
router.push(getUriWithOrg(orgslug, '/signup?orgslug=' + orgslug))
router.push(getUriWithoutOrg(`/signup?orgslug=${orgslug}`))
return
}
const action = isStarted ? removeCourse : startCourse

View file

@ -135,13 +135,11 @@ const BadgesExtension: React.FC = (props: any) => {
return (
<NodeViewWrapper>
<div className='flex space-x-2 items-center'>
<div
className={twMerge(
'flex space-x-1 py-1.5 items-center w-fit rounded-full outline outline-2 outline-white/20 px-3.5 font-semibold nice-shadow text-sm my-2',
getBadgeColor(color)
)}
>
<div className='flex space-x-2 items-center relative'>
<div className={twMerge(
'flex space-x-1 py-1.5 items-center w-fit rounded-full outline outline-2 outline-white/20 px-3.5 font-semibold nice-shadow text-sm my-2',
getBadgeColor(color)
)}>
<div className="flex items-center justify-center space-x-1">
<span className='text'>{emoji}</span>
{isEditable && (
@ -176,6 +174,7 @@ const BadgesExtension: React.FC = (props: any) => {
</div>
)}
</div>
{isEditable && (
<button
onClick={() => setShowPredefinedCallouts(!showPredefinedCallouts)}
@ -184,8 +183,9 @@ const BadgesExtension: React.FC = (props: any) => {
<ChevronRight size={16} />
</button>
)}
{isEditable && showPredefinedCallouts && (
<div className='flex flex-wrap gap-2 absolute mt-8 bg-white/90 backdrop-blur-md p-2 rounded-lg nice-shadow'>
<div className='flex flex-wrap gap-2 absolute top-full mt-2 left-0 bg-white/90 backdrop-blur-md p-2 rounded-lg nice-shadow z-10'>
{predefinedBadges.map((badge, index) => (
<button
key={index}

View file

@ -1,11 +1,66 @@
import { NodeViewWrapper } from '@tiptap/react'
import React, { useState, useRef, useEffect } from 'react'
import React, { useState, useRef, useEffect, useMemo } from 'react'
import { Upload, Link as LinkIcon, GripVertical, GripHorizontal, AlignCenter, Cuboid, Code } from 'lucide-react'
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
import { SiGithub, SiReplit, SiSpotify, SiLoom, SiGooglemaps, SiCodepen, SiCanva, SiNotion, SiGoogledocs, SiGitlab, SiX, SiFigma, SiGiphy } from '@icons-pack/react-simple-icons'
import { useRouter } from 'next/navigation'
import DOMPurify from 'dompurify'
// Add new type for script-based embeds
const SCRIPT_BASED_EMBEDS = {
twitter: { src: 'https://platform.twitter.com/widgets.js', identifier: 'twitter-tweet' },
instagram: { src: 'https://www.instagram.com/embed.js', identifier: 'instagram-media' },
tiktok: { src: 'https://www.tiktok.com/embed.js', identifier: 'tiktok-embed' },
// Add more platforms as needed
};
// Add new memoized component for the embed content
const MemoizedEmbed = React.memo(({ embedUrl, sanitizedEmbedCode, embedType }: {
embedUrl: string;
sanitizedEmbedCode: string;
embedType: 'url' | 'code';
}) => {
useEffect(() => {
if (embedType === 'code' && sanitizedEmbedCode) {
// Check for any matching script-based embeds
const matchingPlatform = Object.entries(SCRIPT_BASED_EMBEDS).find(([_, config]) =>
sanitizedEmbedCode.includes(config.identifier)
);
if (matchingPlatform) {
const [_, config] = matchingPlatform;
const script = document.createElement('script');
script.src = config.src;
script.async = true;
script.charset = 'utf-8';
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}
}
}, [embedType, sanitizedEmbedCode]);
if (embedType === 'url' && embedUrl) {
return (
<iframe
src={embedUrl}
className="w-full h-full"
frameBorder="0"
allowFullScreen
/>
);
}
if (embedType === 'code' && sanitizedEmbedCode) {
return <div dangerouslySetInnerHTML={{ __html: sanitizedEmbedCode }} className="w-full h-full" />;
}
return null;
});
MemoizedEmbed.displayName = 'MemoizedEmbed';
function EmbedObjectsComponent(props: any) {
const [embedType, setEmbedType] = useState<'url' | 'code'>(props.node.attrs.embedType || 'url')
const [embedUrl, setEmbedUrl] = useState(props.node.attrs.embedUrl || '')
@ -13,6 +68,7 @@ function EmbedObjectsComponent(props: any) {
const [embedHeight, setEmbedHeight] = useState(props.node.attrs.embedHeight || 300)
const [embedWidth, setEmbedWidth] = useState(props.node.attrs.embedWidth || '100%')
const [alignment, setAlignment] = useState(props.node.attrs.alignment || 'left')
const [isResizing, setIsResizing] = useState(false)
const resizeRef = useRef<HTMLDivElement>(null)
const editorState = useEditorProvider() as any
@ -53,26 +109,40 @@ function EmbedObjectsComponent(props: any) {
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newUrl = event.target.value;
// Sanitize the URL
const sanitizedUrl = DOMPurify.sanitize(newUrl);
setEmbedUrl(sanitizedUrl);
props.updateAttributes({
embedUrl: sanitizedUrl,
embedType: 'url',
});
const trimmedUrl = newUrl.trim();
// Only update if URL is not just whitespace
if (newUrl === '' || trimmedUrl) {
const sanitizedUrl = DOMPurify.sanitize(newUrl);
setEmbedUrl(sanitizedUrl);
props.updateAttributes({
embedUrl: sanitizedUrl,
embedType: 'url',
});
}
};
const handleCodeChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const newCode = event.target.value;
setEmbedCode(newCode);
props.updateAttributes({
embedCode: newCode,
embedType: 'code',
});
const trimmedCode = newCode.trim();
// Only update if code is not just whitespace
if (newCode === '' || trimmedCode) {
setEmbedCode(newCode);
props.updateAttributes({
embedCode: newCode,
embedType: 'code',
});
}
};
// Add refs for storing dimensions during resize
const dimensionsRef = useRef({
width: props.node.attrs.embedWidth || '100%',
height: props.node.attrs.embedHeight || 300
})
const handleResizeStart = (event: React.MouseEvent<HTMLDivElement>, direction: 'horizontal' | 'vertical') => {
event.preventDefault()
setIsResizing(true)
const startX = event.clientX
const startY = event.clientY
const startWidth = resizeRef.current?.offsetWidth || 0
@ -85,17 +155,29 @@ function EmbedObjectsComponent(props: any) {
const parentWidth = resizeRef.current.parentElement?.offsetWidth || 1
const widthPercentage = Math.min(100, Math.max(10, (newWidth / parentWidth) * 100))
const newWidthValue = `${widthPercentage}%`
setEmbedWidth(newWidthValue)
props.updateAttributes({ embedWidth: newWidthValue })
// Update ref and DOM directly during resize
dimensionsRef.current.width = newWidthValue
resizeRef.current.style.width = newWidthValue
} else {
const newHeight = Math.max(100, startHeight + e.clientY - startY)
setEmbedHeight(newHeight)
props.updateAttributes({ embedHeight: newHeight })
// Update ref and DOM directly during resize
dimensionsRef.current.height = newHeight
resizeRef.current.style.height = `${newHeight}px`
}
}
}
const handleMouseUp = () => {
setIsResizing(false)
// Only update state and attributes after resize is complete
setEmbedWidth(dimensionsRef.current.width)
setEmbedHeight(dimensionsRef.current.height)
props.updateAttributes({
embedWidth: dimensionsRef.current.width,
embedHeight: dimensionsRef.current.height
})
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
@ -114,6 +196,19 @@ function EmbedObjectsComponent(props: any) {
window.open(guide, '_blank', 'noopener,noreferrer')
}
// Memoize the embed content
const embedContent = useMemo(() => (
!isResizing && (embedUrl || sanitizedEmbedCode) ? (
<MemoizedEmbed
embedUrl={embedUrl}
sanitizedEmbedCode={sanitizedEmbedCode}
embedType={embedType}
/>
) : (
<div className="w-full h-full bg-gray-200" />
)
), [embedUrl, sanitizedEmbedCode, embedType, isResizing]);
return (
<NodeViewWrapper className="embed-block">
<div
@ -121,16 +216,7 @@ function EmbedObjectsComponent(props: any) {
className={`relative bg-gray-100 rounded-lg overflow-hidden flex justify-center items-center ${alignment === 'center' ? 'mx-auto' : ''}`}
style={{ height: `${embedHeight}px`, width: embedWidth, minWidth: '400px' }}
>
{embedType === 'url' && embedUrl ? (
<iframe
src={embedUrl}
className="w-full h-full"
frameBorder="0"
allowFullScreen
/>
) : embedType === 'code' && sanitizedEmbedCode ? (
<div dangerouslySetInnerHTML={{ __html: sanitizedEmbedCode }} className="w-full h-full" />
) : (
{(embedUrl || sanitizedEmbedCode) ? embedContent : (
<div className="w-full h-full flex flex-col items-center justify-center p-6">
<p className="text-gray-500 mb-4 font-medium tracking-tighter text-lg">Add an embed from :</p>
<div className="flex flex-wrap gap-5 justify-center">
@ -210,3 +296,4 @@ function EmbedObjectsComponent(props: any) {
}
export default EmbedObjectsComponent

View file

@ -9,19 +9,8 @@ export const NoTextInput = Extension.create({
new Plugin({
key: new PluginKey('noTextInput'),
filterTransaction: (transaction) => {
// If the transaction is adding text, stop it
return (
!transaction.docChanged ||
transaction.steps.every((step) => {
const { slice } = step.toJSON()
return (
!slice ||
!slice.content.some(
(node: { type: string }) => node.type === 'text'
)
)
})
)
// Block all content-changing transactions
return !transaction.docChanged
},
}),
]

View file

@ -291,7 +291,7 @@ function QuizBlockComponent(props: any) {
<div
key={answer.answer_id}
className={twMerge(
'outline outline-3 pr-2 shadow w-full flex items-center space-x-2 h-[30px] bg-opacity-50 hover:bg-opacity-100 hover:shadow-md rounded-s rounded-lg bg-white text-sm hover:scale-105 active:scale-110 duration-150 cursor-pointer ease-linear',
'outline outline-3 pr-2 shadow w-full flex items-center space-x-2 h-[30px] bg-opacity-50 hover:bg-opacity-100 hover:shadow-md rounded-s rounded-lg bg-white text-sm duration-150 cursor-pointer ease-linear',
answer.correct && isEditable ? 'outline-lime-300' : 'outline-white',
userAnswers.some(
(userAnswer: any) =>

View file

@ -4,6 +4,8 @@ import { useLHSession } from '@components/Contexts/LHSessionContext';
import useAdminStatus from '@components/Hooks/useAdminStatus';
import { usePathname, useRouter } from 'next/navigation';
import PageLoading from '@components/Objects/Loaders/PageLoading';
import { getUriWithoutOrg } from '@services/config/config';
import { useOrg } from '@components/Contexts/OrgContext';
type AuthorizationProps = {
children: React.ReactNode;
@ -22,6 +24,7 @@ const ADMIN_PATHS = [
const AdminAuthorization: React.FC<AuthorizationProps> = ({ children, authorizationMode }) => {
const session = useLHSession() as any;
const org = useOrg() as any;
const pathname = usePathname();
const router = useRouter();
const { isAdmin, loading } = useAdminStatus() as any
@ -51,7 +54,7 @@ const AdminAuthorization: React.FC<AuthorizationProps> = ({ children, authorizat
}
if (!isUserAuthenticated) {
router.push('/login');
router.push(getUriWithoutOrg('/login?orgslug=' + org.slug));
return;
}

View file

@ -25,10 +25,10 @@ export const HeaderProfileBox = () => {
<ul className="flex space-x-3 items-center">
<li>
<Link
href={{ pathname: getUriWithoutOrg('/login'), query: org ? { orgslug: org.slug } : null }} >Login</Link>
href={{ pathname: getUriWithoutOrg('/login?orgslug=' + org.slug), query: org ? { orgslug: org.slug } : null }} >Login</Link>
</li>
<li className="bg-black rounded-lg shadow-md p-2 px-3 text-white">
<Link href={{ pathname: getUriWithoutOrg('/signup'), query: org ? { orgslug: org.slug } : null }}>Sign up</Link>
<Link href={{ pathname: getUriWithoutOrg('/signup?orgslug=' + org.slug), query: org ? { orgslug: org.slug } : null }}>Sign up</Link>
</li>
</ul>
</UnidentifiedArea>