Merge branch 'dev' of https://github.com/learnhouse/learnhouse into add_precommit_and_docker_compose_healthcheck

This commit is contained in:
gitea_admin 2024-02-09 17:40:33 +05:45
commit 5558fc0c89
143 changed files with 3677 additions and 1857 deletions

View file

@ -9,16 +9,22 @@ on:
jobs: jobs:
next-lint: next-lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18]
steps: steps:
- name: Checkout code - uses: actions/checkout@v3
uses: actions/checkout@v2 - uses: pnpm/action-setup@v2
- name: Use Node.js
uses: actions/setup-node@v2
with: with:
node-version: 18.x version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies - name: Install dependencies
run: npm ci run: pnpm install
working-directory: ./apps/web working-directory: ./apps/web
- name: Lint code - name: Lint
run: npm run lint run: pnpm run lint
working-directory: ./apps/web working-directory: ./apps/web

View file

@ -15,14 +15,15 @@ LearnHouse is an open source platform that makes it easy for anyone to provide w
![image](https://docs.learnhouse.app/img/pages/features.png) ![image](https://docs.learnhouse.app/img/pages/features.png)
- 📄✨Dynamic notion-like pages - 📄✨Dynamic notion-like pages
- 👨‍🎓 Easy to use - 🏎️ Easy to use
- 👥 Multi-Organization - 👥 Multi-Organization
- 📹 Supports Uploadable Videos and external videos like YouTube - 📹 Supports Uploadable Videos and external videos like YouTube
- 📄 Supports documents like PDF - 📄 Supports documents like PDF
- 🍱 Course Collections - 🍱 Course Collections
- 👨‍🎓 Users Management
- 🙋 Quizzes - 🙋 Quizzes
- 👟 Course progress - 👟 Course progress
- ⚡ (Incoming) Live Collaboration - ✨ LearnHouse AI : The Teachers and Students copilot
- More to come - More to come
## Community ## Community
@ -48,13 +49,14 @@ Thank you for you interest 💖, here is how you can help :
LearnHouse uses a number of open source projects to work properly: LearnHouse uses a number of open source projects to work properly:
- **Next.js** (13 with the App Directory) - The React Framework - **Next.js** (14 with the App Directory) - The React Framework
- **TailwindCSS** - Styling - **TailwindCSS** - Styling
- **Radix UI** - Accessible UI Components - **Radix UI** - Accessible UI Components
- **Tiptap** - An editor framework and headless wrapper around ProseMirror - **Tiptap** - An editor framework and headless wrapper around ProseMirror
- **FastAPI** - A high performance, async API framework for Python - **FastAPI** - A high performance, async API framework for Python
- **YJS** - Shared data types for building collaborative software - **YJS** - Shared data types for building collaborative software
- **MongoDB** - NoSQL Database - **PostgreSQL** - SQL Database
- **LangChain** - LangChain is a framework for developing applications powered by language models
- **React** - duh - **React** - duh
## A word ## A word

2
apps/api/.gitignore vendored
View file

@ -10,7 +10,7 @@ __pycache__/
.vscode/ .vscode/
# Learnhouse # Learnhouse
content/org_* content/*
# Flyio # Flyio
fly.toml fly.toml

View file

@ -9,7 +9,6 @@ from fastapi_jwt_auth.exceptions import AuthJWTException
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
# from src.services.mocks.initial import create_initial_data # from src.services.mocks.initial import create_initial_data
######################## ########################
@ -26,7 +25,6 @@ app = FastAPI(
title=learnhouse_config.site_name, title=learnhouse_config.site_name,
description=learnhouse_config.site_description, description=learnhouse_config.site_description,
version="0.1.0", version="0.1.0",
root_path="/",
) )
app.add_middleware( app.add_middleware(
@ -61,8 +59,8 @@ app.mount("/content", StaticFiles(directory="content"), name="content")
# Global Routes # Global Routes
app.include_router(v1_router) app.include_router(v1_router)
# General Routes # General Routes
@app.get("/") @app.get("/")
async def root(): async def root():
return {"Message": "Welcome to LearnHouse ✨"} return {"Message": "Welcome to LearnHouse ✨"}

1021
apps/api/poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ readme = "README.md"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.11" python = "^3.11"
fastapi = "0.104.1" fastapi = "0.109.1"
pydantic = {version = ">=1.8.0,<2.0.0", extras = ["email"]} pydantic = {version = ">=1.8.0,<2.0.0", extras = ["email"]}
sqlmodel = "0.0.10" sqlmodel = "0.0.10"
uvicorn = "0.23.2" uvicorn = "0.23.2"
@ -34,7 +34,6 @@ langchain = "0.1.0"
tiktoken = "^0.5.2" tiktoken = "^0.5.2"
openai = "^1.7.1" openai = "^1.7.1"
chromadb = "^0.4.22" chromadb = "^0.4.22"
sentence-transformers = "^2.2.2"
python-dotenv = "^1.0.0" python-dotenv = "^1.0.0"
redis = "^5.0.1" redis = "^5.0.1"
langchain-community = "^0.0.11" langchain-community = "^0.0.11"

View file

@ -24,6 +24,5 @@ langchain-openai
tiktoken tiktoken
openai openai
chromadb chromadb
sentence-transformers
python-dotenv python-dotenv
redis redis

View file

@ -1,5 +1,5 @@
from typing import Optional from typing import Optional
from sqlalchemy import JSON, BigInteger, Column, ForeignKey from sqlalchemy import JSON, Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from enum import Enum from enum import Enum
@ -38,12 +38,12 @@ class ActivityBase(SQLModel):
class Activity(ActivityBase, table=True): class Activity(ActivityBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
course_id: int = Field( course_id: int = Field(
default=None, default=None,
sa_column=Column( sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE")),
BigInteger, ForeignKey("course.id", ondelete="CASCADE")
),
) )
activity_uuid: str = "" activity_uuid: str = ""
creation_date: str = "" creation_date: str = ""

View file

@ -21,7 +21,7 @@ class BlockBase(SQLModel):
class Block(BlockBase, table=True): class Block(BlockBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
content: dict = Field(default={}, sa_column=Column(JSON)) content: dict = Field(default={}, sa_column=Column(JSON))
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(sa_column= Column("org_id", ForeignKey("organization.id", ondelete="CASCADE")))
course_id: int = Field(sa_column= Column("course_id", ForeignKey("course.id", ondelete="CASCADE"))) course_id: int = Field(sa_column= Column("course_id", ForeignKey("course.id", ondelete="CASCADE")))
chapter_id: int = Field(sa_column= Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE"))) chapter_id: int = Field(sa_column= Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE")))
activity_id: int = Field(sa_column= Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE"))) activity_id: int = Field(sa_column= Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE")))

View file

@ -1,4 +1,5 @@
from typing import Optional from typing import Optional
from sqlalchemy import BigInteger, Column, ForeignKey
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
@ -10,7 +11,9 @@ class CollectionBase(SQLModel):
class Collection(CollectionBase, table=True): class Collection(CollectionBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE"))
)
collection_uuid: str = "" collection_uuid: str = ""
creation_date: str = "" creation_date: str = ""
update_date: str = "" update_date: str = ""

View file

@ -1,12 +1,12 @@
from typing import Optional from typing import Optional
from sqlalchemy import BigInteger, Column, ForeignKey from sqlalchemy import Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
class CollectionCourse(SQLModel, table=True): class CollectionCourse(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
collection_id: int = Field(sa_column=Column(BigInteger, ForeignKey("collection.id", ondelete="CASCADE"))) collection_id: int = Field(sa_column=Column(Integer, ForeignKey("collection.id", ondelete="CASCADE")))
course_id: int = Field(sa_column=Column(BigInteger, ForeignKey("course.id", ondelete="CASCADE"))) course_id: int = Field(sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE")))
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(default=None, foreign_key="organization.id")
creation_date: str creation_date: str
update_date: str update_date: str

View file

@ -1,5 +1,5 @@
from typing import Optional from typing import Optional
from sqlalchemy import BigInteger, Column, ForeignKey from sqlalchemy import Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
@ -7,10 +7,10 @@ class CourseChapter(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
order: int order: int
course_id: int = Field( course_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("course.id", ondelete="CASCADE")) sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE"))
) )
chapter_id: int = Field( chapter_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("chapter.id", ondelete="CASCADE")) sa_column=Column(Integer, ForeignKey("chapter.id", ondelete="CASCADE"))
) )
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(default=None, foreign_key="organization.id")
creation_date: str creation_date: str

View file

@ -1,4 +1,5 @@
from typing import List, Optional from typing import List, Optional
from sqlalchemy import Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from src.db.users import UserRead from src.db.users import UserRead
from src.db.trails import TrailRead from src.db.trails import TrailRead
@ -17,7 +18,9 @@ class CourseBase(SQLModel):
class Course(CourseBase, table=True): class Course(CourseBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
course_uuid: str = "" course_uuid: str = ""
creation_date: str = "" creation_date: str = ""
update_date: str = "" update_date: str = ""

View file

@ -21,8 +21,8 @@ class AIConfig(BaseModel):
enabled : bool = True enabled : bool = True
limits: AILimitsSettings = AILimitsSettings() limits: AILimitsSettings = AILimitsSettings()
embeddings: Literal[ embeddings: Literal[
"text-embedding-ada-002", "all-MiniLM-L6-v2" "text-embedding-ada-002",
] = "all-MiniLM-L6-v2" ] = "text-embedding-ada-002"
ai_model: Literal["gpt-3.5-turbo", "gpt-4-1106-preview"] = "gpt-3.5-turbo" ai_model: Literal["gpt-3.5-turbo", "gpt-4-1106-preview"] = "gpt-3.5-turbo"
features: AIEnabledFeatures = AIEnabledFeatures() features: AIEnabledFeatures = AIEnabledFeatures()

View file

@ -1,5 +1,8 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from src.db.roles import RoleRead
from src.db.organization_config import OrganizationConfig from src.db.organization_config import OrganizationConfig
@ -32,3 +35,9 @@ class OrganizationRead(OrganizationBase):
config: Optional[OrganizationConfig | dict] config: Optional[OrganizationConfig | dict]
creation_date: str creation_date: str
update_date: str update_date: str
class OrganizationUser(BaseModel):
from src.db.users import UserRead
user: UserRead
role: RoleRead

View file

@ -1,5 +1,6 @@
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from sqlalchemy import Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
@ -12,7 +13,9 @@ class ResourceAuthorshipEnum(str, Enum):
class ResourceAuthor(SQLModel, table=True): class ResourceAuthor(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
resource_uuid: str resource_uuid: str
user_id: int = Field(default=None, foreign_key="user.id") user_id: int = Field(
sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
)
authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR
creation_date: str = "" creation_date: str = ""
update_date: str = "" update_date: str = ""

View file

@ -1,7 +1,7 @@
from enum import Enum from enum import Enum
from typing import Optional, Union from typing import Optional, Union
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
@ -45,7 +45,10 @@ class RoleBase(SQLModel):
class Role(RoleBase, table=True): class Role(RoleBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
org_id: int = Field(default=None, foreign_key="organization.id") org_id: Optional[int] = Field(
default=None,
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
role_type: RoleTypeEnum = RoleTypeEnum.TYPE_GLOBAL role_type: RoleTypeEnum = RoleTypeEnum.TYPE_GLOBAL
role_uuid: str = "" role_uuid: str = ""
creation_date: str = "" creation_date: str = ""

View file

@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from enum import Enum from enum import Enum
@ -23,10 +23,18 @@ class TrailRun(SQLModel, table=True):
data: dict = Field(default={}, sa_column=Column(JSON)) data: dict = Field(default={}, sa_column=Column(JSON))
status: StatusEnum = StatusEnum.STATUS_IN_PROGRESS status: StatusEnum = StatusEnum.STATUS_IN_PROGRESS
# foreign keys # foreign keys
trail_id: int = Field(default=None, foreign_key="trail.id") trail_id: int = Field(
course_id: int = Field(default=None, foreign_key="course.id") sa_column=Column(Integer, ForeignKey("trail.id", ondelete="CASCADE"))
org_id: int = Field(default=None, foreign_key="organization.id") )
user_id: int = Field(default=None, foreign_key="user.id") course_id: int = Field(
sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE"))
)
org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
user_id: int = Field(
sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
)
# timestamps # timestamps
creation_date: str creation_date: str
update_date: str update_date: str

View file

@ -1,7 +1,7 @@
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from sqlalchemy import BigInteger, ForeignKey, JSON, Column from sqlalchemy import ForeignKey, JSON, Column, Integer
class TrailStepTypeEnum(str, Enum): class TrailStepTypeEnum(str, Enum):
@ -18,13 +18,23 @@ class TrailStep(SQLModel, table=True):
data: dict = Field(default={}, sa_column=Column(JSON)) data: dict = Field(default={}, sa_column=Column(JSON))
# foreign keys # foreign keys
trailrun_id: int = Field( trailrun_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("trailrun.id", ondelete="CASCADE")) sa_column=Column(Integer, ForeignKey("trailrun.id", ondelete="CASCADE"))
)
trail_id: int = Field(
sa_column=Column(Integer, ForeignKey("trail.id", ondelete="CASCADE"))
)
activity_id: int = Field(
sa_column=Column(Integer, ForeignKey("activity.id", ondelete="CASCADE"))
)
course_id: int = Field(
sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE"))
)
org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
user_id: int = Field(
sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
) )
trail_id: int = Field(default=None, foreign_key="trail.id")
activity_id: int = Field(default=None, foreign_key="activity.id")
course_id: int = Field(default=None, foreign_key="course.id")
org_id: int = Field(default=None, foreign_key="organization.id")
user_id: int = Field(default=None, foreign_key="user.id")
# timestamps # timestamps
creation_date: str creation_date: str
update_date: str update_date: str

View file

@ -1,5 +1,6 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from src.db.trail_runs import TrailRunRead from src.db.trail_runs import TrailRunRead
@ -24,8 +25,12 @@ class TrailCreate(TrailBase):
class TrailRead(BaseModel): class TrailRead(BaseModel):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
trail_uuid: Optional[str] trail_uuid: Optional[str]
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(
user_id: int = Field(default=None, foreign_key="user.id") sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
user_id: int = Field(
sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
)
creation_date: Optional[str] creation_date: Optional[str]
update_date: Optional[str] update_date: Optional[str]
runs: list[TrailRunRead] runs: list[TrailRunRead]

View file

@ -1,5 +1,5 @@
from typing import Optional from typing import Optional
from sqlalchemy import BigInteger, Column, ForeignKey from sqlalchemy import Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
@ -7,7 +7,7 @@ class UserOrganization(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(default=None, foreign_key="user.id") user_id: int = Field(default=None, foreign_key="user.id")
org_id: int = Field( org_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE")) sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
) )
role_id: int = Field(default=None, foreign_key="role.id") role_id: int = Field(default=None, foreign_key="role.id")
creation_date: str creation_date: str

View file

@ -1,9 +1,8 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from src.db.roles import RoleRead from src.db.roles import RoleRead
from src.db.organizations import OrganizationRead
class UserBase(SQLModel): class UserBase(SQLModel):
@ -45,6 +44,7 @@ class PublicUser(UserRead):
class UserRoleWithOrg(BaseModel): class UserRoleWithOrg(BaseModel):
from src.db.organizations import OrganizationRead
role: RoleRead role: RoleRead
org: OrganizationRead org: OrganizationRead

View file

@ -1,3 +1,4 @@
from datetime import timedelta
from fastapi import Depends, APIRouter, HTTPException, Response, status, Request from fastapi import Depends, APIRouter, HTTPException, Response, status, Request
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session from sqlmodel import Session
@ -10,7 +11,7 @@ from src.security.auth import AuthJWT, authenticate_user
router = APIRouter() router = APIRouter()
@router.post("/refresh") @router.get("/refresh")
def refresh(response: Response, Authorize: AuthJWT = Depends()): def refresh(response: Response, Authorize: AuthJWT = Depends()):
""" """
The jwt_refresh_token_required() function insures a valid refresh The jwt_refresh_token_required() function insures a valid refresh
@ -28,6 +29,7 @@ def refresh(response: Response, Authorize: AuthJWT = Depends()):
value=new_access_token, value=new_access_token,
httponly=False, httponly=False,
domain=get_learnhouse_config().hosting_config.cookie_config.domain, domain=get_learnhouse_config().hosting_config.cookie_config.domain,
expires=int(timedelta(hours=8).total_seconds()),
) )
return {"access_token": new_access_token} return {"access_token": new_access_token}
@ -53,14 +55,16 @@ async def login(
access_token = Authorize.create_access_token(subject=form_data.username) access_token = Authorize.create_access_token(subject=form_data.username)
refresh_token = Authorize.create_refresh_token(subject=form_data.username) refresh_token = Authorize.create_refresh_token(subject=form_data.username)
Authorize.set_refresh_cookies(refresh_token) Authorize.set_refresh_cookies(refresh_token)
# set cookies using fastapi # set cookies using fastapi
response.set_cookie( response.set_cookie(
key="access_token_cookie", key="access_token_cookie",
value=access_token, value=access_token,
httponly=False, httponly=False,
domain=get_learnhouse_config().hosting_config.cookie_config.domain, domain=get_learnhouse_config().hosting_config.cookie_config.domain,
expires=int(timedelta(hours=8).total_seconds()),
) )
user = UserRead.from_orm(user) user = UserRead.from_orm(user)
result = { result = {

View file

@ -31,8 +31,8 @@ async def api_create_course(
name: str = Form(), name: str = Form(),
description: str = Form(), description: str = Form(),
public: bool = Form(), public: bool = Form(),
learnings: str = Form(), learnings: str = Form(None),
tags: str = Form(), tags: str = Form(None),
about: str = Form(), about: str = Form(),
current_user: PublicUser = Depends(get_current_user), current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session), db_session: Session = Depends(get_db_session),

View file

@ -1,6 +1,20 @@
from typing import List from typing import List, Literal
from fastapi import APIRouter, Depends, Request, UploadFile from fastapi import APIRouter, Depends, Request, UploadFile
from sqlmodel import Session from sqlmodel import Session
from src.services.orgs.invites import (
create_invite_code,
delete_invite_code,
get_invite_code,
get_invite_codes,
)
from src.services.orgs.users import (
get_list_of_invited_users,
get_organization_users,
invite_batch_users,
remove_invited_user,
remove_user_from_org,
update_user_role,
)
from src.db.organization_config import OrganizationConfigBase from src.db.organization_config import OrganizationConfigBase
from src.db.users import PublicUser from src.db.users import PublicUser
from src.db.organizations import ( from src.db.organizations import (
@ -8,6 +22,7 @@ from src.db.organizations import (
OrganizationCreate, OrganizationCreate,
OrganizationRead, OrganizationRead,
OrganizationUpdate, OrganizationUpdate,
OrganizationUser,
) )
from src.core.events.database import get_db_session from src.core.events.database import get_db_session
from src.security.auth import get_current_user from src.security.auth import get_current_user
@ -20,6 +35,7 @@ from src.services.orgs.orgs import (
get_orgs_by_user, get_orgs_by_user,
update_org, update_org,
update_org_logo, update_org_logo,
update_org_signup_mechanism,
) )
@ -69,6 +85,166 @@ async def api_get_org(
return await get_organization(request, org_id, db_session, current_user) return await get_organization(request, org_id, db_session, current_user)
@router.get("/{org_id}/users")
async def api_get_org_users(
request: Request,
org_id: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> list[OrganizationUser]:
"""
Get single Org by ID
"""
return await get_organization_users(request, org_id, db_session, current_user)
@router.put("/{org_id}/users/{user_id}/role/{role_uuid}")
async def api_update_user_role(
request: Request,
org_id: str,
user_id: str,
role_uuid: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Update user role
"""
return await update_user_role(
request, org_id, user_id, role_uuid, db_session, current_user
)
@router.delete("/{org_id}/users/{user_id}")
async def api_remove_user_from_org(
request: Request,
org_id: int,
user_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Remove user from org
"""
return await remove_user_from_org(
request, org_id, user_id, db_session, current_user
)
# Config related routes
@router.put("/{org_id}/signup_mechanism")
async def api_get_org_signup_mechanism(
request: Request,
org_id: int,
signup_mechanism: Literal["open", "inviteOnly"],
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Get org signup mechanism
"""
return await update_org_signup_mechanism(
request, signup_mechanism, org_id, current_user, db_session
)
# Invites related routes
@router.post("/{org_id}/invites")
async def api_create_invite_code(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Create invite code
"""
return await create_invite_code(request, org_id, current_user, db_session)
@router.get("/{org_id}/invites")
async def api_get_invite_codes(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Get invite codes
"""
return await get_invite_codes(request, org_id, current_user, db_session)
@router.get("/{org_id}/invites/code/{invite_code}")
async def api_get_invite_code(
request: Request,
org_id: int,
invite_code: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Get invite code
"""
print(f"org_id: {org_id}, invite_code: {invite_code}")
return await get_invite_code(request, org_id,invite_code, current_user, db_session)
@router.delete("/{org_id}/invites/{org_invite_code_uuid}")
async def api_delete_invite_code(
request: Request,
org_id: int,
org_invite_code_uuid: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Delete invite code
"""
return await delete_invite_code(
request, org_id, org_invite_code_uuid, current_user, db_session
)
@router.post("/{org_id}/invites/users/batch")
async def api_invite_batch_users(
request: Request,
org_id: int,
users: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Invite batch users
"""
return await invite_batch_users(request, org_id, users, db_session, current_user)
@router.get("/{org_id}/invites/users")
async def api_get_org_users_invites(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Get org users invites
"""
return await get_list_of_invited_users(request, org_id, db_session, current_user)
@router.delete("/{org_id}/invites/users/{email}")
async def api_delete_org_users_invites(
request: Request,
org_id: int,
email: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Delete org users invites
"""
return await remove_invited_user(request, org_id, email, db_session, current_user)
@router.get("/slug/{org_slug}") @router.get("/slug/{org_slug}")
async def api_get_org_by_slug( async def api_get_org_by_slug(
request: Request, request: Request,

View file

@ -1,6 +1,7 @@
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from sqlmodel import Session from sqlmodel import Session
from src.services.orgs.orgs import get_org_join_mechanism
from src.security.auth import get_current_user from src.security.auth import get_current_user
from src.core.events.database import get_db_session from src.core.events.database import get_db_session
@ -16,12 +17,14 @@ from src.db.users import (
from src.services.users.users import ( from src.services.users.users import (
authorize_user_action, authorize_user_action,
create_user, create_user,
create_user_with_invite,
create_user_without_org, create_user_without_org,
delete_user_by_id, delete_user_by_id,
get_user_session, get_user_session,
read_user_by_id, read_user_by_id,
read_user_by_uuid, read_user_by_uuid,
update_user, update_user,
update_user_avatar,
update_user_password, update_user_password,
) )
@ -77,7 +80,48 @@ async def api_create_user_with_orgid(
""" """
Create User with Org ID Create User with Org ID
""" """
return await create_user(request, db_session, current_user, user_object, org_id) print(await get_org_join_mechanism(request, org_id, current_user, db_session))
# TODO(fix) : This is temporary, logic should be moved to service
if (
await get_org_join_mechanism(request, org_id, current_user, db_session)
== "inviteOnly"
):
raise HTTPException(
status_code=403,
detail="You need an invite to join this organization",
)
else:
return await create_user(request, db_session, current_user, user_object, org_id)
@router.post("/{org_id}/invite/{invite_code}", response_model=UserRead, tags=["users"])
async def api_create_user_with_orgid_and_invite(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
user_object: UserCreate,
invite_code: str,
org_id: int,
) -> UserRead:
"""
Create User with Org ID and invite code
"""
# TODO: This is temporary, logic should be moved to service
if (
await get_org_join_mechanism(request, org_id, current_user, db_session)
== "inviteOnly"
):
return await create_user_with_invite(
request, db_session, current_user, user_object, org_id, invite_code
)
else:
raise HTTPException(
status_code=403,
detail="This organization does not require an invite code",
)
@router.post("/", response_model=UserRead, tags=["users"]) @router.post("/", response_model=UserRead, tags=["users"])
@ -137,6 +181,20 @@ async def api_update_user(
return await update_user(request, db_session, user_id, current_user, user_object) return await update_user(request, db_session, user_id, current_user, user_object)
@router.put("/update_avatar/{user_id}", response_model=UserRead, tags=["users"])
async def api_update_avatar_user(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
avatar_file: UploadFile | None = None,
) -> UserRead:
"""
Update User
"""
return await update_user_avatar(request, db_session, current_user, avatar_file)
@router.put("/change_password/{user_id}", response_model=UserRead, tags=["users"]) @router.put("/change_password/{user_id}", response_model=UserRead, tags=["users"])
async def api_update_user_password( async def api_update_user_password(
*, *,

View file

@ -21,7 +21,9 @@ class Settings(BaseModel):
authjwt_secret_key: str = "secret" if isDevModeEnabled() else SECRET_KEY authjwt_secret_key: str = "secret" if isDevModeEnabled() else SECRET_KEY
authjwt_token_location = {"cookies", "headers"} authjwt_token_location = {"cookies", "headers"}
authjwt_cookie_csrf_protect = False authjwt_cookie_csrf_protect = False
authjwt_access_token_expires = False if isDevModeEnabled() else 28800 authjwt_access_token_expires = (
False if isDevModeEnabled() else timedelta(hours=8).total_seconds()
)
authjwt_cookie_samesite = "lax" authjwt_cookie_samesite = "lax"
authjwt_cookie_secure = True authjwt_cookie_secure = True
authjwt_cookie_domain = get_learnhouse_config().hosting_config.cookie_config.domain authjwt_cookie_domain = get_learnhouse_config().hosting_config.cookie_config.domain

View file

@ -16,12 +16,11 @@ async def authorization_verify_if_element_is_public(
element_uuid: str, element_uuid: str,
action: Literal["read"], action: Literal["read"],
db_session: Session, db_session: Session,
): ):
element_nature = await check_element_type(element_uuid) element_nature = await check_element_type(element_uuid)
# Verifies if the element is public # Verifies if the element is public
if element_nature == ("courses" or "collections") and action == "read": if element_nature == ("courses") and action == "read":
if element_nature == "courses": if element_nature == "courses":
print("looking for course")
statement = select(Course).where( statement = select(Course).where(
Course.public == True, Course.course_uuid == element_uuid Course.public == True, Course.course_uuid == element_uuid
) )
@ -29,20 +28,29 @@ async def authorization_verify_if_element_is_public(
if course: if course:
return True return True
else: else:
return False raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User rights : You don't have the right to perform this action",
)
if element_nature == "collections" and action == "read":
if element_nature == "collections":
statement = select(Collection).where( statement = select(Collection).where(
Collection.public == True, Collection.collection_uuid == element_uuid Collection.public == True, Collection.collection_uuid == element_uuid
) )
collection = db_session.exec(statement).first() collection = db_session.exec(statement).first()
if collection: if collection:
return True return True
else: else:
return False raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User rights : You don't have the right to perform this action",
)
else: else:
return False raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User rights : You don't have the right to perform this action",
)
# Tested and working # Tested and working
@ -106,6 +114,34 @@ async def authorization_verify_based_on_roles(
return False return False
async def authorization_verify_based_on_org_admin_status(
request: Request,
user_id: int,
action: Literal["read", "update", "delete", "create"],
element_uuid: str,
db_session: Session,
):
await check_element_type(element_uuid)
# Get user roles bound to an organization and standard roles
statement = (
select(Role)
.join(UserOrganization)
.where((UserOrganization.org_id == Role.org_id) | (Role.org_id == null()))
.where(UserOrganization.user_id == user_id)
)
user_roles_in_organization_and_standard_roles = db_session.exec(statement).all()
# Find in roles list if there is a role that matches users action for this type of element
for role in user_roles_in_organization_and_standard_roles:
role = Role.from_orm(role)
if role.id == 1 or role.id == 2:
return True
else:
return False
# Tested and working # Tested and working
async def authorization_verify_based_on_roles_and_authorship( async def authorization_verify_based_on_roles_and_authorship(
request: Request, request: Request,

View file

@ -5,7 +5,6 @@ async def check_element_type(element_id):
""" """
Check if the element is a course, a user, a house or a collection, by checking its prefix Check if the element is a course, a user, a house or a collection, by checking its prefix
""" """
print("element_id", element_id)
if element_id.startswith("course_"): if element_id.startswith("course_"):
return "courses" return "courses"
elif element_id.startswith("user_"): elif element_id.startswith("user_"):

View file

@ -67,8 +67,11 @@ def ai_start_activity_chat_session(
# Serialize Activity Content Blocks to a text comprehensible by the AI # Serialize Activity Content Blocks to a text comprehensible by the AI
structured = structure_activity_content_by_type(content) structured = structure_activity_content_by_type(content)
isEmpty = structured == []
ai_friendly_text = serialize_activity_text_to_ai_comprehensible_text( ai_friendly_text = serialize_activity_text_to_ai_comprehensible_text(
structured, course, activity structured, course, activity, isActivityEmpty=isEmpty
) )
# Get Activity Organization # Get Activity Organization

View file

@ -2,7 +2,6 @@ from typing import Optional
from uuid import uuid4 from uuid import uuid4
from langchain.agents import AgentExecutor from langchain.agents import AgentExecutor
from langchain.text_splitter import CharacterTextSplitter from langchain.text_splitter import CharacterTextSplitter
from langchain.embeddings.sentence_transformer import SentenceTransformerEmbeddings
from langchain_community.vectorstores import Chroma from langchain_community.vectorstores import Chroma
from langchain.agents.openai_functions_agent.base import OpenAIFunctionsAgent from langchain.agents.openai_functions_agent.base import OpenAIFunctionsAgent
from langchain.prompts import MessagesPlaceholder from langchain.prompts import MessagesPlaceholder
@ -45,7 +44,6 @@ def ask_ai(
texts = text_splitter.split_documents(documents) texts = text_splitter.split_documents(documents)
embedding_models = { embedding_models = {
"all-MiniLM-L6-v2": SentenceTransformerEmbeddings,
"text-embedding-ada-002": OpenAIEmbeddings, "text-embedding-ada-002": OpenAIEmbeddings,
} }
@ -53,11 +51,11 @@ def ask_ai(
if embedding_model_name in embedding_models: if embedding_model_name in embedding_models:
if embedding_model_name == "text-embedding-ada-002": if embedding_model_name == "text-embedding-ada-002":
embedding_function = embedding_models[embedding_model_name](model=embedding_model_name, api_key=openai_api_key) embedding_function = embedding_models[embedding_model_name](
if embedding_model_name == "all-MiniLM-L6-v2": model=embedding_model_name, api_key=openai_api_key
embedding_function = embedding_models[embedding_model_name](model_name=embedding_model_name) )
else: else:
embedding_function = embedding_models[embedding_model_name](model_name=embedding_model_name) raise Exception("Embedding model not found")
# load it into Chroma and use it as a retriever # load it into Chroma and use it as a retriever
db = Chroma.from_documents(texts, embedding_function) db = Chroma.from_documents(texts, embedding_function)
@ -75,7 +73,10 @@ def ask_ai(
memory_key = "history" memory_key = "history"
memory = AgentTokenBufferMemory( memory = AgentTokenBufferMemory(
memory_key=memory_key, llm=llm, chat_memory=message_history, max_token_limit=1000 memory_key=memory_key,
llm=llm,
chat_memory=message_history,
max_token_limit=1000,
) )
system_message = SystemMessage(content=(message_for_the_prompt)) system_message = SystemMessage(content=(message_for_the_prompt))

View file

@ -50,7 +50,8 @@ async def upload_file_and_return_file_object(
await upload_content( await upload_content(
f"courses/{course_uuid}/activities/{activity_uuid}/dynamic/blocks/{type_of_block}/{block_id}", f"courses/{course_uuid}/activities/{activity_uuid}/dynamic/blocks/{type_of_block}/{block_id}",
org_uuid=org_uuid, type_of_dir='orgs',
uuid=org_uuid,
file_binary=file_binary, file_binary=file_binary,
file_and_format=f"{file_id}.{file_format}", file_and_format=f"{file_id}.{file_format}",
) )

View file

@ -1,5 +1,6 @@
from typing import Literal from typing import Literal
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.courses import Course
from src.db.chapters import Chapter from src.db.chapters import Chapter
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship,
@ -25,7 +26,6 @@ async def create_activity(
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
db_session: Session, db_session: Session,
): ):
activity = Activity.from_orm(activity_object)
# CHeck if org exists # CHeck if org exists
statement = select(Chapter).where(Chapter.id == activity_object.chapter_id) statement = select(Chapter).where(Chapter.id == activity_object.chapter_id)
@ -40,6 +40,9 @@ async def create_activity(
# RBAC check # RBAC check
await rbac_check(request, chapter.chapter_uuid, current_user, "create", db_session) await rbac_check(request, chapter.chapter_uuid, current_user, "create", db_session)
# Create Activity
activity = Activity(**activity_object.dict())
activity.activity_uuid = str(f"activity_{uuid4()}") activity.activity_uuid = str(f"activity_{uuid4()}")
activity.creation_date = str(datetime.now()) activity.creation_date = str(datetime.now())
activity.update_date = str(datetime.now()) activity.update_date = str(datetime.now())
@ -96,8 +99,18 @@ async def get_activity(
detail="Activity not found", detail="Activity not found",
) )
# Get course from that activity
statement = select(Course).where(Course.id == activity.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# RBAC check # RBAC check
await rbac_check(request, activity.activity_uuid, current_user, "read", db_session) await rbac_check(request, course.course_uuid, current_user, "read", db_session)
activity = ActivityRead.from_orm(activity) activity = ActivityRead.from_orm(activity)
@ -223,7 +236,6 @@ async def rbac_check(
res = await authorization_verify_if_element_is_public( res = await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session request, course_uuid, action, db_session
) )
print('res',res)
return res return res
else: else:
res = await authorization_verify_based_on_roles_and_authorship( res = await authorization_verify_based_on_roles_and_authorship(

View file

@ -8,6 +8,7 @@ async def upload_pdf(pdf_file, activity_uuid, org_uuid, course_uuid):
try: try:
await upload_content( await upload_content(
f"courses/{course_uuid}/activities/{activity_uuid}/documentpdf", f"courses/{course_uuid}/activities/{activity_uuid}/documentpdf",
"orgs",
org_uuid, org_uuid,
contents, contents,
f"documentpdf.{pdf_format}", f"documentpdf.{pdf_format}",

View file

@ -9,6 +9,7 @@ async def upload_video(video_file, activity_uuid, org_uuid, course_uuid):
try: try:
await upload_content( await upload_content(
f"courses/{course_uuid}/activities/{activity_uuid}/video", f"courses/{course_uuid}/activities/{activity_uuid}/video",
'orgs',
org_uuid, org_uuid,
contents, contents,
f"video.{video_format}", f"video.{video_format}",

View file

@ -4,6 +4,10 @@ from src.db.courses import CourseRead
def structure_activity_content_by_type(activity): def structure_activity_content_by_type(activity):
### Get Headings, Texts, Callouts, Answers and Paragraphs from the activity as a big list of strings (text only) and return it ### Get Headings, Texts, Callouts, Answers and Paragraphs from the activity as a big list of strings (text only) and return it
if "content" not in activity or not activity["content"]:
return []
content = activity["content"] content = activity["content"]
headings = [] headings = []
@ -11,10 +15,12 @@ def structure_activity_content_by_type(activity):
paragraphs = [] paragraphs = []
for item in content: for item in content:
if 'content' in item: if "content" in item:
if item["type"] == "heading" and "text" in item["content"][0]: if item["type"] == "heading" and "text" in item["content"][0]:
headings.append(item["content"][0]["text"]) headings.append(item["content"][0]["text"])
elif item["type"] in ["calloutInfo", "calloutWarning"] and all("text" in text_item for text_item in item["content"]): elif item["type"] in ["calloutInfo", "calloutWarning"] and all(
"text" in text_item for text_item in item["content"]
):
callouts.append( callouts.append(
"".join([text_item["text"] for text_item in item["content"]]) "".join([text_item["text"] for text_item in item["content"]])
) )
@ -34,15 +40,29 @@ def structure_activity_content_by_type(activity):
# Add Paragraphs # Add Paragraphs
data_array.append({"Paragraphs": paragraphs}) data_array.append({"Paragraphs": paragraphs})
print(data_array)
return data_array return data_array
def serialize_activity_text_to_ai_comprehensible_text( def serialize_activity_text_to_ai_comprehensible_text(
data_array, course: CourseRead, activity: ActivityRead data_array,
course: CourseRead,
activity: ActivityRead,
isActivityEmpty: bool = False,
): ):
### Serialize the text to a format that is comprehensible by the AI
if isActivityEmpty:
text = (
"Use this as a context "
+ 'This is a course about "'
+ course.name
+ '". '
+ 'This is a lecture about "'
+ activity.name
+ '". '
+ "There is no content yet in this lecture."
)
return text
# Serialize Headings # Serialize Headings
serialized_headings = "" serialized_headings = ""
@ -51,7 +71,6 @@ def serialize_activity_text_to_ai_comprehensible_text(
# Serialize Callouts # Serialize Callouts
serialized_callouts = "" serialized_callouts = ""
for callout in data_array[1]["Callouts"]: for callout in data_array[1]["Callouts"]:
serialized_callouts += callout + " " serialized_callouts += callout + " "

View file

@ -112,8 +112,17 @@ async def get_chapter(
status_code=status.HTTP_409_CONFLICT, detail="Chapter does not exist" status_code=status.HTTP_409_CONFLICT, detail="Chapter does not exist"
) )
# get COurse
statement = select(Course).where(Course.id == chapter.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Course does not exist"
)
# RBAC check # RBAC check
await rbac_check(request, chapter.chapter_uuid, current_user, "read", db_session) await rbac_check(request, course.course_uuid, current_user, "read", db_session)
# Get activities for this chapter # Get activities for this chapter
statement = ( statement = (
@ -208,7 +217,7 @@ async def get_course_chapters(
page: int = 1, page: int = 1,
limit: int = 10, limit: int = 10,
) -> List[ChapterRead]: ) -> List[ChapterRead]:
statement = select(Course).where(Course.id == course_id) statement = select(Course).where(Course.id == course_id)
course = db_session.exec(statement).first() course = db_session.exec(statement).first()
@ -225,7 +234,7 @@ async def get_course_chapters(
chapters = [ChapterRead(**chapter.dict(), activities=[]) for chapter in chapters] chapters = [ChapterRead(**chapter.dict(), activities=[]) for chapter in chapters]
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await rbac_check(request, course.course_uuid, current_user, "read", db_session) # type: ignore
# Get activities for each chapter # Get activities for each chapter
for chapter in chapters: for chapter in chapters:
@ -473,12 +482,15 @@ async def reorder_chapters_and_activities(
db_session.delete(chapter_activity) db_session.delete(chapter_activity)
db_session.commit() db_session.commit()
# If links do not exist, create them # If links do not exist, create them
chapter_activity_map = {} chapter_activity_map = {}
for chapter_order in chapters_order.chapter_order_by_ids: for chapter_order in chapters_order.chapter_order_by_ids:
for activity_order in chapter_order.activities_order_by_ids: for activity_order in chapter_order.activities_order_by_ids:
if activity_order.activity_id in chapter_activity_map and chapter_activity_map[activity_order.activity_id] != chapter_order.chapter_id: if (
activity_order.activity_id in chapter_activity_map
and chapter_activity_map[activity_order.activity_id]
!= chapter_order.chapter_id
):
continue continue
statement = ( statement = (
@ -547,7 +559,7 @@ async def rbac_check(
res = await authorization_verify_if_element_is_public( res = await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session request, course_uuid, action, db_session
) )
print('res',res) print("res", res)
return res return res
else: else:
res = await authorization_verify_based_on_roles_and_authorship( res = await authorization_verify_based_on_roles_and_authorship(

View file

@ -26,7 +26,10 @@ from fastapi import HTTPException, status, Request
async def get_collection( async def get_collection(
request: Request, collection_uuid: str, current_user: PublicUser, db_session: Session request: Request,
collection_uuid: str,
current_user: PublicUser,
db_session: Session,
) -> CollectionRead: ) -> CollectionRead:
statement = select(Collection).where(Collection.collection_uuid == collection_uuid) statement = select(Collection).where(Collection.collection_uuid == collection_uuid)
collection = db_session.exec(statement).first() collection = db_session.exec(statement).first()
@ -42,11 +45,23 @@ async def get_collection(
) )
# get courses in collection # get courses in collection
statement = ( statement_all = (
select(Course) select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id) .join(CollectionCourse, Course.id == CollectionCourse.course_id)
.distinct(Course.id) .distinct(Course.id)
) )
statement_public = (
select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id)
.where(CollectionCourse.org_id == collection.org_id, Course.public == True)
)
if current_user.id == 0:
statement = statement_public
else:
statement = statement_all
courses = db_session.exec(statement).all() courses = db_session.exec(statement).all()
collection = CollectionRead(**collection.dict(), courses=courses) collection = CollectionRead(**collection.dict(), courses=courses)
@ -180,7 +195,10 @@ async def update_collection(
async def delete_collection( async def delete_collection(
request: Request, collection_uuid: str, current_user: PublicUser, db_session: Session request: Request,
collection_uuid: str,
current_user: PublicUser,
db_session: Session,
): ):
statement = select(Collection).where(Collection.collection_uuid == collection_uuid) statement = select(Collection).where(Collection.collection_uuid == collection_uuid)
collection = db_session.exec(statement).first() collection = db_session.exec(statement).first()
@ -216,23 +234,40 @@ async def get_collections(
page: int = 1, page: int = 1,
limit: int = 10, limit: int = 10,
) -> List[CollectionRead]: ) -> List[CollectionRead]:
# RBAC check
await rbac_check(request, "collection_x", current_user, "read", db_session)
statement = ( statement_public = select(Collection).where(
Collection.org_id == org_id, Collection.public == True
)
statement_all = (
select(Collection).where(Collection.org_id == org_id).distinct(Collection.id) select(Collection).where(Collection.org_id == org_id).distinct(Collection.id)
) )
if current_user.id == 0:
statement = statement_public
else:
statement = statement_all
collections = db_session.exec(statement).all() collections = db_session.exec(statement).all()
collections_with_courses = [] collections_with_courses = []
for collection in collections: for collection in collections:
statement = ( statement_all = (
select(Course) select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id) .join(CollectionCourse, Course.id == CollectionCourse.course_id)
.distinct(Course.id) .distinct(Course.id)
) )
statement_public = (
select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id)
.where(CollectionCourse.org_id == org_id, Course.public == True)
)
if current_user.id == 0:
statement = statement_public
else:
# RBAC check
statement = statement_all
courses = db_session.exec(statement).all() courses = db_session.exec(statement).all()
collection = CollectionRead(**collection.dict(), courses=courses) collection = CollectionRead(**collection.dict(), courses=courses)
@ -256,8 +291,11 @@ async def rbac_check(
res = await authorization_verify_if_element_is_public( res = await authorization_verify_if_element_is_public(
request, collection_uuid, action, db_session request, collection_uuid, action, db_session
) )
print('res',res) if res == False:
return res raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User rights : You are not allowed to read this collection",
)
else: else:
res = await authorization_verify_based_on_roles_and_authorship( res = await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, collection_uuid, db_session request, current_user.id, action, collection_uuid, db_session
@ -276,4 +314,3 @@ async def rbac_check(
## 🔒 RBAC Utils ## ## 🔒 RBAC Utils ##

View file

@ -146,6 +146,9 @@ async def create_course(
) )
course.thumbnail_image = name_in_disk course.thumbnail_image = name_in_disk
else:
course.thumbnail_image = ""
# Insert course # Insert course
db_session.add(course) db_session.add(course)
db_session.commit() db_session.commit()

View file

@ -1,13 +1,13 @@
from src.services.utils.upload_content import upload_content from src.services.utils.upload_content import upload_content
async def upload_thumbnail(thumbnail_file, name_in_disk, org_id, course_id): async def upload_thumbnail(thumbnail_file, name_in_disk, org_uuid, course_id):
contents = thumbnail_file.file.read() contents = thumbnail_file.file.read()
try: try:
await upload_content( await upload_content(
f"courses/{course_id}/thumbnails", f"courses/{course_id}/thumbnails",
org_id, "orgs",
org_uuid,
contents, contents,
f"{name_in_disk}", f"{name_in_disk}",
) )

View file

@ -0,0 +1,253 @@
import json
import random
import string
import uuid
import redis
from datetime import datetime, timedelta
from sqlmodel import Session, select
from config.config import get_learnhouse_config
from src.services.orgs.orgs import rbac_check
from src.db.users import AnonymousUser, PublicUser
from src.db.organizations import (
Organization,
)
from fastapi import HTTPException, Request
async def create_invite_code(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Redis init
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "update", db_session)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
# Check if this org has more than 6 invite codes
invite_codes = r.keys(f"*:org:{org.org_uuid}:code:*")
if len(invite_codes) >= 6:
raise HTTPException(
status_code=400,
detail="Organization has reached the maximum number of invite codes",
)
# Generate invite code
def generate_code(length=5):
letters_and_digits = string.ascii_letters + string.digits
return "".join(random.choice(letters_and_digits) for _ in range(length))
generated_invite_code = generate_code()
invite_code_uuid = f"org_invite_code_{uuid.uuid4()}"
# time to live in days to seconds
ttl = int(timedelta(days=365).total_seconds())
inviteCodeObject = {
"invite_code": generated_invite_code,
"invite_code_uuid": invite_code_uuid,
"invite_code_expires": ttl,
"invite_code_type": "signup",
"created_at": datetime.now().isoformat(),
"created_by": current_user.user_uuid,
}
r.set(
f"{invite_code_uuid}:org:{org.org_uuid}:code:{generated_invite_code}",
json.dumps(inviteCodeObject),
ex=ttl,
)
return inviteCodeObject
async def get_invite_codes(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Redis init
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "update", db_session)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
# Get invite codes
invite_codes = r.keys(f"org_invite_code_*:org:{org.org_uuid}:code:*")
invite_codes_list = []
for invite_code in invite_codes:
invite_code = r.get(invite_code)
invite_code = json.loads(invite_code)
invite_codes_list.append(invite_code)
return invite_codes_list
async def get_invite_code(
request: Request,
org_id: int,
invite_code: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Redis init
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
# await rbac_check(request, org.org_uuid, current_user, "update", db_session)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
# Get invite code
invite_code = r.keys(f"org_invite_code_*:org:{org.org_uuid}:code:{invite_code}") # type: ignore
if not invite_code:
raise HTTPException(
status_code=404,
detail="Invite code not found",
)
invite_code = r.get(invite_code[0]) # type: ignore
invite_code = json.loads(invite_code)
return invite_code
async def delete_invite_code(
request: Request,
org_id: int,
invite_code_uuid: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Redis init
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "update", db_session)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
# Delete invite code
keys = r.keys(f"{invite_code_uuid}:org:{org.org_uuid}:code:*")
if keys:
r.delete(*keys)
if not keys:
raise HTTPException(
status_code=404,
detail="Invite code not found",
)
return keys

View file

@ -9,6 +9,7 @@ async def upload_org_logo(logo_file, org_uuid):
await upload_content( await upload_content(
"logos", "logos",
"orgs",
org_uuid, org_uuid,
contents, contents,
name_in_disk, name_in_disk,

View file

@ -15,7 +15,7 @@ from src.db.organization_config import (
OrganizationConfigBase, OrganizationConfigBase,
) )
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_org_admin_status,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
from src.db.users import AnonymousUser, PublicUser from src.db.users import AnonymousUser, PublicUser
@ -169,7 +169,7 @@ async def create_org(
limits_enabled=False, limits_enabled=False,
max_asks=0, max_asks=0,
), ),
embeddings="all-MiniLM-L6-v2", embeddings="text-embedding-ada-002",
ai_model="gpt-3.5-turbo", ai_model="gpt-3.5-turbo",
features=AIEnabledFeatures( features=AIEnabledFeatures(
editor=False, editor=False,
@ -438,12 +438,106 @@ async def get_orgs_by_user(
return orgs return orgs
# Config related
async def update_org_signup_mechanism(
request: Request,
signup_mechanism: Literal["open", "inviteOnly"],
org_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "update", db_session)
# Get org config
statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org.id)
result = db_session.exec(statement)
org_config = result.first()
if org_config is None:
logging.error(f"Organization {org_id} has no config")
raise HTTPException(
status_code=404,
detail="Organization config not found",
)
updated_config = org_config.config
# Update config
updated_config = OrganizationConfigBase(**updated_config)
updated_config.GeneralConfig.users.signup_mechanism = signup_mechanism
# Update the database
org_config.config = json.loads(updated_config.json())
org_config.update_date = str(datetime.now())
db_session.add(org_config)
db_session.commit()
db_session.refresh(org_config)
return {"detail": "Signup mechanism updated"}
async def get_org_join_mechanism(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
# Get org config
statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org.id)
result = db_session.exec(statement)
org_config = result.first()
if org_config is None:
logging.error(f"Organization {org_id} has no config")
raise HTTPException(
status_code=404,
detail="Organization config not found",
)
config = org_config.config
# Get the signup mechanism
config = OrganizationConfigBase(**config)
signup_mechanism = config.GeneralConfig.users.signup_mechanism
return signup_mechanism
## 🔒 RBAC Utils ## ## 🔒 RBAC Utils ##
async def rbac_check( async def rbac_check(
request: Request, request: Request,
org_id: str, org_uuid: str,
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"], action: Literal["create", "read", "update", "delete"],
db_session: Session, db_session: Session,
@ -453,11 +547,25 @@ async def rbac_check(
return True return True
else: else:
await authorization_verify_if_user_is_anon(current_user.id) isUserAnon = await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship( isAllowedOnOrgAdminStatus = (
request, current_user.id, action, org_id, db_session await authorization_verify_based_on_org_admin_status(
request, current_user.id, action, org_uuid, db_session
)
) )
if isUserAnon:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="You should be logged in to be able to achieve this action",
)
if not isAllowedOnOrgAdminStatus:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User rights (admin status) : You don't have the right to perform this action",
)
## 🔒 RBAC Utils ## ## 🔒 RBAC Utils ##

View file

@ -0,0 +1,410 @@
from datetime import datetime, timedelta
import json
import logging
import redis
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from config.config import get_learnhouse_config
from src.services.orgs.orgs import rbac_check
from src.db.roles import Role, RoleRead
from src.db.users import AnonymousUser, PublicUser, User, UserRead
from src.db.user_organizations import UserOrganization
from src.db.organizations import (
Organization,
OrganizationUser,
)
async def get_organization_users(
request: Request,
org_id: str,
db_session: Session,
current_user: PublicUser | AnonymousUser,
) -> list[OrganizationUser]:
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
statement = (
select(User)
.join(UserOrganization)
.join(Organization)
.where(Organization.id == org_id)
)
users = db_session.exec(statement)
users = users.all()
org_users_list = []
for user in users:
statement = select(UserOrganization).where(
UserOrganization.user_id == user.id, UserOrganization.org_id == org_id
)
result = db_session.exec(statement)
user_org = result.first()
if not user_org:
logging.error(f"User {user.id} not found")
# skip this user
continue
statement = select(Role).where(Role.id == user_org.role_id)
result = db_session.exec(statement)
role = result.first()
if not role:
logging.error(f"Role {user_org.role_id} not found")
# skip this user
continue
statement = select(User).where(User.id == user_org.user_id)
result = db_session.exec(statement)
user = result.first()
if not user:
logging.error(f"User {user_org.user_id} not found")
# skip this user
continue
user = UserRead.from_orm(user)
role = RoleRead.from_orm(role)
org_user = OrganizationUser(
user=user,
role=role,
)
org_users_list.append(org_user)
return org_users_list
async def remove_user_from_org(
request: Request,
org_id: int,
user_id: int,
db_session: Session,
current_user: PublicUser | AnonymousUser,
):
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "delete", db_session)
statement = select(UserOrganization).where(
UserOrganization.user_id == user_id, UserOrganization.org_id == org.id
)
result = db_session.exec(statement)
user_org = result.first()
if not user_org:
raise HTTPException(
status_code=404,
detail="User not found",
)
# Check if user is the last admin
statement = select(UserOrganization).where(
UserOrganization.org_id == org.id, UserOrganization.role_id == 1
)
result = db_session.exec(statement)
admins = result.all()
if len(admins) == 1 and admins[0].user_id == user_id:
raise HTTPException(
status_code=400,
detail="You can't remove the last admin of the organization",
)
db_session.delete(user_org)
db_session.commit()
return {"detail": "User removed from org"}
async def update_user_role(
request: Request,
org_id: str,
user_id: str,
role_uuid: str,
db_session: Session,
current_user: PublicUser | AnonymousUser,
):
# find role
statement = select(Role).where(Role.role_uuid == role_uuid)
result = db_session.exec(statement)
role = result.first()
if not role:
raise HTTPException(
status_code=404,
detail="Role not found",
)
role_id = role.id
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "update", db_session)
# Check if user is the last admin and if the new role is not admin
statement = select(UserOrganization).where(
UserOrganization.org_id == org.id, UserOrganization.role_id == 1
)
result = db_session.exec(statement)
admins = result.all()
if not admins:
raise HTTPException(
status_code=400,
detail="There is no admin in the organization",
)
if (
len(admins) == 1
and int(admins[0].user_id) == int(user_id)
and str(role_uuid) != "role_global_admin"
):
raise HTTPException(
status_code=400,
detail="Organization must have at least one admin",
)
statement = select(UserOrganization).where(
UserOrganization.user_id == user_id, UserOrganization.org_id == org.id
)
result = db_session.exec(statement)
user_org = result.first()
if not user_org:
raise HTTPException(
status_code=404,
detail="User not found",
)
if role_id is not None:
user_org.role_id = role_id
db_session.add(user_org)
db_session.commit()
db_session.refresh(user_org)
return {"detail": "User role updated"}
async def invite_batch_users(
request: Request,
org_id: int,
emails: str,
db_session: Session,
current_user: PublicUser | AnonymousUser,
):
# Redis init
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "create", db_session)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
invite_list = emails.split(",")
# invitations expire after 30 days
ttl = int(timedelta(days=365).total_seconds())
for email in invite_list:
email = email.strip()
# Check if user is already invited
invited_user = r.get(f"invited_user:{email}:org:{org.org_uuid}")
if invited_user:
logging.error(f"User {email} already invited")
# skip this user
continue
invited_user_object = {
"email": email,
"org_id": org.id,
"pending": True,
"email_sent": False,
"expires": ttl,
"created_at": datetime.now().isoformat(),
"created_by": current_user.user_uuid,
}
invited_user = r.set(
f"invited_user:{email}:org:{org.org_uuid}",
json.dumps(invited_user_object),
ex=ttl,
)
return {"detail": "Users invited"}
async def get_list_of_invited_users(
request: Request,
org_id: int,
db_session: Session,
current_user: PublicUser | AnonymousUser,
):
# Redis init
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
invited_users = r.keys(f"invited_user:*:org:{org.org_uuid}")
invited_users_list = []
for user in invited_users:
invited_user = r.get(user)
if invited_user:
invited_user = json.loads(invited_user.decode("utf-8"))
invited_users_list.append(invited_user)
return invited_users_list
async def remove_invited_user(
request: Request,
org_id: int,
email: str,
db_session: Session,
current_user: PublicUser | AnonymousUser,
):
# Redis init
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "delete", db_session)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
invited_user = r.get(f"invited_user:{email}:org:{org.org_uuid}")
if not invited_user:
raise HTTPException(
status_code=404,
detail="User not found",
)
r.delete(f"invited_user:{email}:org:{org.org_uuid}")
return {"detail": "User removed"}

View file

@ -17,7 +17,9 @@ async def create_user_trail(
trail_object: TrailCreate, trail_object: TrailCreate,
db_session: Session, db_session: Session,
) -> Trail: ) -> Trail:
statement = select(Trail).where(Trail.org_id == trail_object.org_id, Trail.user_id == user.id) statement = select(Trail).where(
Trail.org_id == trail_object.org_id, Trail.user_id == user.id
)
trail = db_session.exec(statement).first() trail = db_session.exec(statement).first()
if trail: if trail:
@ -124,7 +126,7 @@ async def check_trail_presence(
async def get_user_trail_with_orgid( async def get_user_trail_with_orgid(
request: Request, user: PublicUser | AnonymousUser, org_id: int, db_session: Session request: Request, user: PublicUser | AnonymousUser, org_id: int, db_session: Session
) -> TrailRead: ) -> TrailRead:
if isinstance(user, AnonymousUser): if isinstance(user, AnonymousUser):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@ -151,7 +153,7 @@ async def get_user_trail_with_orgid(
for trail_run in trail_runs: for trail_run in trail_runs:
statement = select(Course).where(Course.id == trail_run.course_id) statement = select(Course).where(Course.id == trail_run.course_id)
course = db_session.exec(statement).first() course = db_session.exec(statement).first()
trail_run.course = course trail_run.course = course
# Add number of activities (steps) in a course # Add number of activities (steps) in a course
statement = select(ChapterActivity).where( statement = select(ChapterActivity).where(
@ -213,7 +215,7 @@ async def add_activity_to_trail(
) )
statement = select(TrailRun).where( statement = select(TrailRun).where(
TrailRun.trail_id == trail.id, TrailRun.course_id == course.id TrailRun.trail_id == trail.id, TrailRun.course_id == course.id, TrailRun.user_id == user.id
) )
trailrun = db_session.exec(statement).first() trailrun = db_session.exec(statement).first()
@ -231,7 +233,7 @@ async def add_activity_to_trail(
db_session.refresh(trailrun) db_session.refresh(trailrun)
statement = select(TrailStep).where( statement = select(TrailStep).where(
TrailStep.trailrun_id == trailrun.id, TrailStep.activity_id == activity.id TrailStep.trailrun_id == trailrun.id, TrailStep.activity_id == activity.id, TrailStep.user_id == user.id
) )
trailstep = db_session.exec(statement).first() trailstep = db_session.exec(statement).first()
@ -253,7 +255,7 @@ async def add_activity_to_trail(
db_session.commit() db_session.commit()
db_session.refresh(trailstep) db_session.refresh(trailstep)
statement = select(TrailRun).where(TrailRun.trail_id == trail.id) statement = select(TrailRun).where(TrailRun.trail_id == trail.id , TrailRun.user_id == user.id)
trail_runs = db_session.exec(statement).all() trail_runs = db_session.exec(statement).all()
trail_runs = [ trail_runs = [
@ -262,7 +264,7 @@ async def add_activity_to_trail(
] ]
for trail_run in trail_runs: for trail_run in trail_runs:
statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id) statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id, TrailStep.user_id == user.id)
trail_steps = db_session.exec(statement).all() trail_steps = db_session.exec(statement).all()
trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps] trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps]
@ -296,7 +298,9 @@ async def add_course_to_trail(
) )
# check if run already exists # check if run already exists
statement = select(TrailRun).where(TrailRun.course_id == course.id) statement = select(TrailRun).where(
TrailRun.course_id == course.id, TrailRun.user_id == user.id
)
trailrun = db_session.exec(statement).first() trailrun = db_session.exec(statement).first()
if trailrun: if trailrun:
@ -315,7 +319,7 @@ async def add_course_to_trail(
) )
statement = select(TrailRun).where( statement = select(TrailRun).where(
TrailRun.trail_id == trail.id, TrailRun.course_id == course.id TrailRun.trail_id == trail.id, TrailRun.course_id == course.id, TrailRun.user_id == user.id
) )
trail_run = db_session.exec(statement).first() trail_run = db_session.exec(statement).first()
@ -332,7 +336,7 @@ async def add_course_to_trail(
db_session.commit() db_session.commit()
db_session.refresh(trail_run) db_session.refresh(trail_run)
statement = select(TrailRun).where(TrailRun.trail_id == trail.id) statement = select(TrailRun).where(TrailRun.trail_id == trail.id, TrailRun.user_id == user.id)
trail_runs = db_session.exec(statement).all() trail_runs = db_session.exec(statement).all()
trail_runs = [ trail_runs = [
@ -341,7 +345,7 @@ async def add_course_to_trail(
] ]
for trail_run in trail_runs: for trail_run in trail_runs:
statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id) statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id , TrailStep.user_id == user.id)
trail_steps = db_session.exec(statement).all() trail_steps = db_session.exec(statement).all()
trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps] trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps]
@ -385,7 +389,7 @@ async def remove_course_from_trail(
) )
statement = select(TrailRun).where( statement = select(TrailRun).where(
TrailRun.trail_id == trail.id, TrailRun.course_id == course.id TrailRun.trail_id == trail.id, TrailRun.course_id == course.id, TrailRun.user_id == user.id
) )
trail_run = db_session.exec(statement).first() trail_run = db_session.exec(statement).first()
@ -394,14 +398,14 @@ async def remove_course_from_trail(
db_session.commit() db_session.commit()
# Delete all trail steps for this course # Delete all trail steps for this course
statement = select(TrailStep).where(TrailStep.course_id == course.id) statement = select(TrailStep).where(TrailStep.course_id == course.id, TrailStep.user_id == user.id)
trail_steps = db_session.exec(statement).all() trail_steps = db_session.exec(statement).all()
for trail_step in trail_steps: for trail_step in trail_steps:
db_session.delete(trail_step) db_session.delete(trail_step)
db_session.commit() db_session.commit()
statement = select(TrailRun).where(TrailRun.trail_id == trail.id) statement = select(TrailRun).where(TrailRun.trail_id == trail.id, TrailRun.user_id == user.id)
trail_runs = db_session.exec(statement).all() trail_runs = db_session.exec(statement).all()
trail_runs = [ trail_runs = [
@ -410,7 +414,7 @@ async def remove_course_from_trail(
] ]
for trail_run in trail_runs: for trail_run in trail_runs:
statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id) statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id, TrailStep.user_id == user.id)
trail_steps = db_session.exec(statement).all() trail_steps = db_session.exec(statement).all()
trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps] trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps]

View file

@ -0,0 +1,16 @@
from src.services.utils.upload_content import upload_content
async def upload_avatar(avatar_file, name_in_disk, user_uuid):
contents = avatar_file.file.read()
try:
await upload_content(
"avatars",
"users",
user_uuid,
contents,
f"{name_in_disk}",
)
except Exception:
return {"message": "There was an error uploading the file"}

View file

@ -1,13 +1,15 @@
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Literal
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException, Request, status from fastapi import HTTPException, Request, UploadFile, status
from sqlmodel import Session, select from sqlmodel import Session, select
from src.services.orgs.invites import get_invite_code
from src.services.users.avatars import upload_avatar
from src.db.roles import Role, RoleRead from src.db.roles import Role, RoleRead
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
from src.db.organizations import Organization, OrganizationRead from src.db.organizations import Organization, OrganizationRead
from src.db.users import ( from src.db.users import (
AnonymousUser, AnonymousUser,
@ -102,6 +104,27 @@ async def create_user(
return user return user
async def create_user_with_invite(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
user_object: UserCreate,
org_id: int,
invite_code: str,
):
# Check if invite code exists
isInviteCodeCorrect = await get_invite_code(request, org_id, invite_code, current_user, db_session)
if not isInviteCodeCorrect:
raise HTTPException(
status_code=400,
detail="Invite code is incorrect",
)
user = await create_user(request, db_session, current_user, user_object, org_id)
return user
async def create_user_without_org( async def create_user_without_org(
request: Request, request: Request,
@ -195,6 +218,49 @@ async def update_user(
return user return user
async def update_user_avatar(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
avatar_file: UploadFile | None = None,
):
# Get user
statement = select(User).where(User.id == current_user.id)
user = db_session.exec(statement).first()
if not user:
raise HTTPException(
status_code=400,
detail="User does not exist",
)
# RBAC check
await rbac_check(request, current_user, "update", user.user_uuid, db_session)
# Upload thumbnail
if avatar_file and avatar_file.filename:
name_in_disk = f"{user.user_uuid}_avatar_{uuid4()}.{avatar_file.filename.split('.')[-1]}"
await upload_avatar(avatar_file, name_in_disk, user.user_uuid)
# Update course
if name_in_disk:
user.avatar_image = name_in_disk
else:
raise HTTPException(
status_code=500,
detail="Issue with Avatar upload",
)
# Update user in database
db_session.add(user)
db_session.commit()
db_session.refresh(user)
user = UserRead.from_orm(user)
return user
async def update_user_password( async def update_user_password(
request: Request, request: Request,
db_session: Session, db_session: Session,

View file

@ -1,3 +1,4 @@
from typing import Literal
import boto3 import boto3
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
import os import os
@ -6,7 +7,11 @@ from config.config import get_learnhouse_config
async def upload_content( async def upload_content(
directory: str, org_uuid: str, file_binary: bytes, file_and_format: str directory: str,
type_of_dir: Literal["orgs", "users"],
uuid: str, # org_uuid or user_uuid
file_binary: bytes,
file_and_format: str,
): ):
# Get Learnhouse Config # Get Learnhouse Config
learnhouse_config = get_learnhouse_config() learnhouse_config = get_learnhouse_config()
@ -16,12 +21,12 @@ async def upload_content(
if content_delivery == "filesystem": if content_delivery == "filesystem":
# create folder for activity # create folder for activity
if not os.path.exists(f"content/{org_uuid}/{directory}"): if not os.path.exists(f"content/{type_of_dir}/{uuid}/{directory}"):
# create folder for activity # create folder for activity
os.makedirs(f"content/{org_uuid}/{directory}") os.makedirs(f"content/{type_of_dir}/{uuid}/{directory}")
# upload file to server # upload file to server
with open( with open(
f"content/{org_uuid}/{directory}/{file_and_format}", f"content/{type_of_dir}/{uuid}/{directory}/{file_and_format}",
"wb", "wb",
) as f: ) as f:
f.write(file_binary) f.write(file_binary)
@ -37,13 +42,13 @@ async def upload_content(
) )
# Create folder for activity # Create folder for activity
if not os.path.exists(f"content/{org_uuid}/{directory}"): if not os.path.exists(f"content/{type_of_dir}/{uuid}/{directory}"):
# create folder for activity # create folder for activity
os.makedirs(f"content/{org_uuid}/{directory}") os.makedirs(f"content/{type_of_dir}/{uuid}/{directory}")
# Upload file to server # Upload file to server
with open( with open(
f"content/{org_uuid}/{directory}/{file_and_format}", f"content/{type_of_dir}/{uuid}/{directory}/{file_and_format}",
"wb", "wb",
) as f: ) as f:
f.write(file_binary) f.write(file_binary)
@ -52,9 +57,9 @@ async def upload_content(
print("Uploading to s3 using boto3...") print("Uploading to s3 using boto3...")
try: try:
s3.upload_file( s3.upload_file(
f"content/{org_uuid}/{directory}/{file_and_format}", f"content/{type_of_dir}/{uuid}/{directory}/{file_and_format}",
"learnhouse-media", "learnhouse-media",
f"content/{org_uuid}/{directory}/{file_and_format}", f"content/{type_of_dir}/{uuid}/{directory}/{file_and_format}",
) )
except ClientError as e: except ClientError as e:
print(e) print(e)
@ -63,7 +68,7 @@ async def upload_content(
try: try:
s3.head_object( s3.head_object(
Bucket="learnhouse-media", Bucket="learnhouse-media",
Key=f"content/{org_uuid}/{directory}/{file_and_format}", Key=f"content/{type_of_dir}/{uuid}/{directory}/{file_and_format}",
) )
print("File upload successful!") print("File upload successful!")
except Exception as e: except Exception as e:

View file

@ -3,6 +3,8 @@
"rules": { "rules": {
"react/no-unescaped-entities": "off", "react/no-unescaped-entities": "off",
"@next/next/no-page-custom-font": "off", "@next/next/no-page-custom-font": "off",
"@next/next/no-img-element": "off" "@next/next/no-img-element": "off",
} "unused-imports/no-unused-imports": "warn"
},
"plugins": ["unused-imports"]
} }

View file

@ -1,5 +1,5 @@
# #
FROM node:16-alpine FROM node:18-alpine
# #
WORKDIR /usr/learnhouse/front WORKDIR /usr/learnhouse/front

View file

@ -4,11 +4,10 @@ import { getCourseMetadataWithAuthHeader } from "@services/courses/courses";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { Metadata } from "next"; import { Metadata } from "next";
import { getActivityWithAuthHeader } from "@services/courses/activities"; import { getActivityWithAuthHeader } from "@services/courses/activities";
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth"; import { getAccessTokenFromRefreshTokenCookie } from "@services/auth/auth";
import { getOrganizationContextInfo, getOrganizationContextInfoWithId } from "@services/organizations/orgs"; import { getOrganizationContextInfoWithId } from "@services/organizations/orgs";
import SessionProvider from "@components/Contexts/SessionContext"; import SessionProvider from "@components/Contexts/SessionContext";
import EditorOptionsProvider from "@components/Contexts/Editor/EditorContext"; import EditorOptionsProvider from "@components/Contexts/Editor/EditorContext";
import AIChatBotProvider from "@components/Contexts/AI/AIChatBotContext";
import AIEditorProvider from "@components/Contexts/AI/AIEditorContext"; import AIEditorProvider from "@components/Contexts/AI/AIEditorContext";
type MetadataProps = { type MetadataProps = {

View file

@ -1,13 +1,24 @@
'use client' 'use client'
import React, { use, useEffect } from 'react' import React, { useEffect } from 'react'
import { INSTALL_STEPS } from './steps/steps' import { INSTALL_STEPS } from './steps/steps'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
function InstallClient() { function InstallClient() {
return (
<GeneralWrapperStyled>
<Suspense>
<>
<Stepscomp />
</>
</Suspense>
</GeneralWrapperStyled>
)
}
const Stepscomp = () => {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
const step: any = parseInt(searchParams.get('step') || '0'); const step: any = parseInt(searchParams.get('step') || '0');
@ -24,7 +35,7 @@ function InstallClient() {
}, [step]) }, [step])
return ( return (
<GeneralWrapperStyled> <div>
<div className='flex justify-center '> <div className='flex justify-center '>
<div className='grow'> <div className='grow'>
<LearnHouseLogo /> <LearnHouseLogo />
@ -54,7 +65,7 @@ function InstallClient() {
{stepsState[stepNumber].component} {stepsState[stepNumber].component}
</div> </div>
</div> </div>
</GeneralWrapperStyled> </div>
) )
} }

View file

@ -1,5 +1,5 @@
"use client"; "use client";
import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input } from '@components/StyledElements/Form/Form' import FormLayout, { ButtonBlack, FormField, FormLabelAndMessage, Input } from '@components/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form'; import * as Form from '@radix-ui/react-form';
import { getAPIUrl } from '@services/config/config'; import { getAPIUrl } from '@services/config/config';
import { createNewUserInstall, updateInstall } from '@services/install/install'; import { createNewUserInstall, updateInstall } from '@services/install/install';
@ -8,7 +8,7 @@ import { useFormik } from 'formik';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import React from 'react' import React from 'react'
import { BarLoader } from 'react-spinners'; import { BarLoader } from 'react-spinners';
import useSWR, { mutate } from "swr"; import useSWR from "swr";
const validate = (values: any) => { const validate = (values: any) => {
const errors: any = {}; const errors: any = {};

View file

@ -2,7 +2,7 @@ import PageLoading from '@components/Objects/Loaders/PageLoading';
import { getAPIUrl } from '@services/config/config'; import { getAPIUrl } from '@services/config/config';
import { swrFetcher } from '@services/utils/ts/requests'; import { swrFetcher } from '@services/utils/ts/requests';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import React, { use, useEffect } from 'react' import React, { useEffect } from 'react'
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
function GetStarted() { function GetStarted() {

View file

@ -1,13 +1,12 @@
import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input } from '@components/StyledElements/Form/Form' import FormLayout, { ButtonBlack, FormField, FormLabelAndMessage, Input } from '@components/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form'; import * as Form from '@radix-ui/react-form';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { BarLoader } from 'react-spinners'; import { BarLoader } from 'react-spinners';
import React from 'react' import React from 'react'
import { createNewOrganization } from '@services/organizations/orgs';
import { swrFetcher } from '@services/utils/ts/requests'; import { swrFetcher } from '@services/utils/ts/requests';
import { getAPIUrl } from '@services/config/config'; import { getAPIUrl } from '@services/config/config';
import useSWR, { mutate } from "swr"; import useSWR from "swr";
import { createNewOrgInstall, updateInstall } from '@services/install/install'; import { createNewOrgInstall, updateInstall } from '@services/install/install';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';

View file

@ -3,7 +3,7 @@ import { createSampleDataInstall, updateInstall } from '@services/install/instal
import { swrFetcher } from '@services/utils/ts/requests'; import { swrFetcher } from '@services/utils/ts/requests';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import React from 'react' import React from 'react'
import useSWR, { mutate } from "swr"; import useSWR from "swr";
function SampleData() { function SampleData() {
const { data: install, error: error, isLoading } = useSWR(`${getAPIUrl()}install/latest`, swrFetcher); const { data: install, error: error, isLoading } = useSWR(`${getAPIUrl()}install/latest`, swrFetcher);

View file

@ -1,6 +1,6 @@
import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper"; import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper";
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth"; import { getAccessTokenFromRefreshTokenCookie } from "@services/auth/auth";
import { getBackendUrl, getUriWithOrg } from "@services/config/config"; import { getUriWithOrg } from "@services/config/config";
import { getCollectionByIdWithAuthHeader } from "@services/courses/collections"; import { getCollectionByIdWithAuthHeader } from "@services/courses/collections";
import { getCourseThumbnailMediaDirectory } from "@services/media/media"; import { getCourseThumbnailMediaDirectory } from "@services/media/media";
import { getOrganizationContextInfo } from "@services/organizations/orgs"; import { getOrganizationContextInfo } from "@services/organizations/orgs";

View file

@ -1,29 +1,26 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React from "react"; import React, { useState } from "react";
import { createCollection } from "@services/courses/collections"; import { createCollection } from "@services/courses/collections";
import useSWR from "swr"; import useSWR from "swr";
import { getAPIUrl, getUriWithOrg } from "@services/config/config"; import { getAPIUrl, getUriWithOrg } from "@services/config/config";
import { revalidateTags, swrFetcher } from "@services/utils/ts/requests"; import { revalidateTags, swrFetcher } from "@services/utils/ts/requests";
import { getOrganizationContextInfo } from "@services/organizations/orgs"; import { useOrg } from "@components/Contexts/OrgContext";
function NewCollection(params: any) { function NewCollection(params: any) {
const org = useOrg() as any;
const orgslug = params.params.orgslug; const orgslug = params.params.orgslug;
const [name, setName] = React.useState(""); const [name, setName] = React.useState("");
const [org, setOrg] = React.useState({}) as any;
const [description, setDescription] = React.useState(""); const [description, setDescription] = React.useState("");
const [selectedCourses, setSelectedCourses] = React.useState([]) as any; const [selectedCourses, setSelectedCourses] = React.useState([]) as any;
const router = useRouter(); const router = useRouter();
const { data: courses, error: error } = useSWR(`${getAPIUrl()}courses/org_slug/${orgslug}/page/1/limit/10`, swrFetcher); const { data: courses, error: error } = useSWR(`${getAPIUrl()}courses/org_slug/${orgslug}/page/1/limit/10`, swrFetcher);
const [isPublic, setIsPublic] = useState('true');
const handleVisibilityChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setIsPublic(e.target.value);
};
React.useEffect(() => {
async function getOrg() {
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800 });
setOrg(org);
}
getOrg();
}, []);
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value); setName(event.target.value);
@ -35,83 +32,94 @@ function NewCollection(params: any) {
const handleSubmit = async (e: any) => { const handleSubmit = async (e: any) => {
e.preventDefault(); e.preventDefault();
const collection = { const collection = {
name: name, name: name,
description: description, description: description,
courses: selectedCourses, courses: selectedCourses,
public: true, public: isPublic,
org_id: org.id, org_id: org.id,
}; };
await createCollection(collection); await createCollection(collection);
await revalidateTags(["collections"], orgslug); await revalidateTags(["collections"], org.slug);
// reload the page
router.refresh(); router.refresh();
router.prefetch(getUriWithOrg(orgslug, "/collections"));
router.push(getUriWithOrg(orgslug, "/collections")); // wait for 2s before reloading the page
setTimeout(() => {
router.push(getUriWithOrg(orgslug, "/collections"));
}
, 1000);
}; };
return ( return (
<> <>
<div className="w-64 m-auto py-20"> <div className="w-64 m-auto py-20">
<div className="font-bold text-lg mb-4">Add new</div> <div className="font-bold text-lg mb-4">Add new</div>
<input <input
type="text" type="text"
placeholder="Name" placeholder="Name"
value={name} value={name}
onChange={handleNameChange} onChange={handleNameChange}
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
{!courses ? ( <select
onChange={handleVisibilityChange}
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
defaultValue={isPublic}
>
<option value="false">Private Collection</option>
<option value="true">Public Collection </option>
</select>
{!courses ? (
<p className="text-gray-500">Loading...</p> <p className="text-gray-500">Loading...</p>
) : ( ) : (
<div> <div className="space-y-4 p-3">
<p>Courses</p>
{courses.map((course: any) => ( {courses.map((course: any) => (
<div key={course.course_uuid} className="flex items-center mb-2"> <div key={course.course_uuid} className="flex items-center space-x-2">
<input <input
type="checkbox"
id={course.id}
name={course.name}
value={course.id}
onChange={(e) => {
if (e.target.checked) {
setSelectedCourses([...selectedCourses, course.id]);
}
else {
setSelectedCourses(selectedCourses.filter((course_uuid: any) => course_uuid !== course.course_uuid));
}
}}
className="text-blue-500 rounded focus:ring-2 focus:ring-blue-500"
/>
type="checkbox" <label htmlFor={course.course_uuid} className="text-sm text-gray-700">{course.name}</label>
id={course.id}
name={course.name}
value={course.id}
// id is an integer, not a string
onChange={(e) => {
if (e.target.checked) {
setSelectedCourses([...selectedCourses, course.id]);
}
else {
setSelectedCourses(selectedCourses.filter((course_uuid: any) => course_uuid !== course.course_uuid));
}
}
}
className="mr-2"
/>
<label htmlFor={course.course_uuid} className="text-sm">{course.name}</label>
</div> </div>
))} ))}
</div> </div>
)} )}
<input <input
type="text" type="text"
placeholder="Description" placeholder="Description"
value={description} value={description}
onChange={handleDescriptionChange} onChange={handleDescriptionChange}
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
<button <button
onClick={handleSubmit} onClick={handleSubmit}
className="px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-black focus:outline-none focus:ring-2 focus:ring-blue-500" className="px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-black focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
Submit Submit
</button> </button>
</div> </div>
</> </>

View file

@ -4,7 +4,7 @@ import { cookies } from "next/headers";
import ActivityClient from "./activity"; import ActivityClient from "./activity";
import { getOrganizationContextInfo } from "@services/organizations/orgs"; import { getOrganizationContextInfo } from "@services/organizations/orgs";
import { Metadata } from "next"; import { Metadata } from "next";
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth"; import { getAccessTokenFromRefreshTokenCookie } from "@services/auth/auth";
type MetadataProps = { type MetadataProps = {

View file

@ -1,18 +1,17 @@
"use client"; "use client";
import { removeCourse, startCourse } from "@services/courses/activity"; import { removeCourse, startCourse } from "@services/courses/activity";
import Link from "next/link"; import Link from "next/link";
import React, { use, useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { getUriWithOrg } from "@services/config/config"; import { getUriWithOrg } from "@services/config/config";
import PageLoading from "@components/Objects/Loaders/PageLoading"; import PageLoading from "@components/Objects/Loaders/PageLoading";
import { revalidateTags } from "@services/utils/ts/requests"; import { revalidateTags } from "@services/utils/ts/requests";
import ActivityIndicators from "@components/Pages/Courses/ActivityIndicators"; import ActivityIndicators from "@components/Pages/Courses/ActivityIndicators";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper"; import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper";
import { getCourseThumbnailMediaDirectory } from "@services/media/media"; import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } from "@services/media/media";
import { ArrowRight, Check, File, Sparkles, Star, Video } from "lucide-react"; import { ArrowRight, Check, File, Sparkles, Video } from "lucide-react";
import Avvvatars from "avvvatars-react";
import { getUser } from "@services/users/users";
import { useOrg } from "@components/Contexts/OrgContext"; import { useOrg } from "@components/Contexts/OrgContext";
import UserAvatar from "@components/Objects/UserAvatar";
const CourseClient = (props: any) => { const CourseClient = (props: any) => {
const [user, setUser] = useState<any>({}); const [user, setUser] = useState<any>({});
@ -25,7 +24,7 @@ const CourseClient = (props: any) => {
function getLearningTags() { function getLearningTags() {
// create array of learnings from a string object (comma separated) // create array of learnings from a string object (comma separated)
let learnings = course.learnings.split(","); let learnings = course?.learnings ? course?.learnings.split(",") : [];
setLearnings(learnings); setLearnings(learnings);
} }
@ -56,13 +55,13 @@ const CourseClient = (props: any) => {
} }
useEffect(() => { useEffect(() => {
getLearningTags();
} }
, [org]); , [org, course]);
return ( return (
<> <>
{!course ? ( {!course && !org ? (
<PageLoading></PageLoading> <PageLoading></PageLoading>
) : ( ) : (
<GeneralWrapperStyled> <GeneralWrapperStyled>
@ -73,9 +72,13 @@ const CourseClient = (props: any) => {
</h1> </h1>
</div> </div>
{props.course?.thumbnail_image && org ?
<div className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-auto h-[300px] bg-cover bg-center mb-4" style={{ backgroundImage: `url(${getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)})` }}> <div className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-auto h-[400px] bg-cover bg-center mb-4" style={{ backgroundImage: `url(${getCourseThumbnailMediaDirectory(org?.org_uuid, course?.course_uuid, course?.thumbnail_image)})` }}>
</div> </div>
:
<div className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-auto h-[400px] bg-cover bg-center mb-4" style={{ backgroundImage: `url('../empty_thumbnail.png')`, backgroundSize: 'auto' }}>
</div>
}
<ActivityIndicators course_uuid={props.course.course_uuid} orgslug={orgslug} course={course} /> <ActivityIndicators course_uuid={props.course.course_uuid} orgslug={orgslug} course={course} />
@ -86,21 +89,25 @@ const CourseClient = (props: any) => {
<p className="py-5 px-5">{course.description}</p> <p className="py-5 px-5">{course.description}</p>
</div> </div>
<h2 className="py-3 text-2xl font-bold">What you will learn</h2> {learnings.length > 0 && learnings[0] !== "null" &&
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden px-5 py-5 space-y-2"> <div>
{learnings.map((learning: any) => { <h2 className="py-3 text-2xl font-bold">What you will learn</h2>
return ( <div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden px-5 py-5 space-y-2">
<div key={learning} {learnings.map((learning: any) => {
className="flex space-x-2 items-center font-semibold text-gray-500 capitalize"> return (
<div className="px-2 py-2 rounded-full"> <div key={learning}
<Check className="text-gray-400" size={15} /> className="flex space-x-2 items-center font-semibold text-gray-500">
</div> <div className="px-2 py-2 rounded-full">
<p>{learning}</p> <Check className="text-gray-400" size={15} />
</div> </div>
); <p>{learning}</p>
} </div>
)} );
</div> }
)}
</div>
</div>
}
<h2 className="py-3 text-2xl font-bold">Course Lessons</h2> <h2 className="py-3 text-2xl font-bold">Course Lessons</h2>
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden"> <div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
@ -185,15 +192,22 @@ const CourseClient = (props: any) => {
</div> </div>
</div> </div>
<div className="course_metadata_right space-y-3 w-64 antialiased flex flex-col ml-10 h-fit p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden"> <div className="course_metadata_right space-y-3 w-72 antialiased flex flex-col ml-10 h-fit p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
{user && {user &&
<div className="flex mx-auto space-x-3 px-2 py-2 items-center"> <div className="flex flex-col mx-auto space-y-3 px-2 py-2 items-center">
<div className=""> <UserAvatar border="border-8" avatar_url={getUserAvatarMediaDirectory(course.authors[0].user_uuid, course.authors[0].avatar_image)} width={100} />
<Avvvatars border borderSize={5} borderColor="white" size={50} shadow value={course.authors[0].username} style='shape' />
</div>
<div className="-space-y-2 "> <div className="-space-y-2 ">
<div className="text-[12px] text-neutral-400 font-semibold">Author</div> <div className="text-[12px] text-neutral-400 font-semibold">Author</div>
<div className="text-xl font-bold text-neutral-800">{course.authors[0].first_name} {course.authors[0].last_name} {(course.authors[0].first_name && course.authors[0].last_name) ? course.authors[0].first_name + ' ' + course.authors[0].last_name : course.authors[0].username}</div> <div className="text-xl font-bold text-neutral-800">
{course.authors[0].first_name && course.authors[0].last_name && (
<div className="flex space-x-2 items-center">
<p>{course.authors[0].first_name + ' ' + course.authors[0].last_name}</p><span className="text-xs bg-neutral-100 p-1 px-3 rounded-full text-neutral-400 font-semibold"> @{course.authors[0].username}</span>
</div>)}
{!course.authors[0].first_name && !course.authors[0].last_name && (
<div className="flex space-x-2 items-center">
<p>@{course.authors[0].username}</p>
</div>)}
</div>
</div> </div>
</div> </div>
} }
@ -214,12 +228,4 @@ const CourseClient = (props: any) => {
}; };
const StyledBox = (props: any) => (
<div className="p-3 pl-10 bg-white w-[100%] h-auto ring-1 ring-inset ring-gray-400/10 rounded-lg shadow-sm">
{props.children}
</div>
);
export default CourseClient; export default CourseClient;

View file

@ -4,7 +4,7 @@ import { cookies } from 'next/headers';
import { getCourseMetadataWithAuthHeader } from '@services/courses/courses'; import { getCourseMetadataWithAuthHeader } from '@services/courses/courses';
import { getOrganizationContextInfo } from '@services/organizations/orgs'; import { getOrganizationContextInfo } from '@services/organizations/orgs';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from '@services/auth/auth'; import { getAccessTokenFromRefreshTokenCookie } from '@services/auth/auth';
type MetadataProps = { type MetadataProps = {
params: { orgslug: string, courseuuid: string }; params: { orgslug: string, courseuuid: string };

View file

@ -5,7 +5,7 @@ import { getOrgCoursesWithAuthHeader } from "@services/courses/courses";
import { Metadata } from "next"; import { Metadata } from "next";
import { getOrganizationContextInfo } from "@services/organizations/orgs"; import { getOrganizationContextInfo } from "@services/organizations/orgs";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth"; import { getAccessTokenFromRefreshTokenCookie } from "@services/auth/auth";
type MetadataProps = { type MetadataProps = {
params: { orgslug: string }; params: { orgslug: string };

View file

@ -17,7 +17,7 @@ export default function Error({
return ( return (
<div> <div>
<ErrorUI></ErrorUI> <ErrorUI ></ErrorUI>
</div> </div>
); );
} }

View file

@ -12,7 +12,6 @@ import { getAccessTokenFromRefreshTokenCookie } from '@services/auth/auth';
import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail'; import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail';
import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail'; import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail';
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'; import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement';
import { Plus, PlusCircle } from 'lucide-react';
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton'; import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton';
import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton'; import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton';

View file

@ -5,10 +5,9 @@ import TrailCourseElement from "@components/Pages/Trail/TrailCourseElement";
import TypeOfContentTitle from "@components/StyledElements/Titles/TypeOfContentTitle"; import TypeOfContentTitle from "@components/StyledElements/Titles/TypeOfContentTitle";
import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper"; import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper";
import { getAPIUrl } from "@services/config/config"; import { getAPIUrl } from "@services/config/config";
import { removeCourse } from "@services/courses/activity"; import { swrFetcher } from "@services/utils/ts/requests";
import { revalidateTags, swrFetcher } from "@services/utils/ts/requests";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import useSWR, { mutate } from "swr"; import useSWR from "swr";
function Trail(params: any) { function Trail(params: any) {
let orgslug = params.orgslug; let orgslug = params.orgslug;

View file

@ -5,7 +5,6 @@ import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail';
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'; import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement';
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton'; import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton';
import Modal from '@components/StyledElements/Modal/Modal'; import Modal from '@components/StyledElements/Modal/Modal';
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import React from 'react' import React from 'react'

View file

@ -1,20 +1,13 @@
'use client'; 'use client';
import EditCourseStructure from '../../../../../../../../components/Dashboard/Course/EditCourseStructure/EditCourseStructure' import EditCourseStructure from '../../../../../../../../components/Dashboard/Course/EditCourseStructure/EditCourseStructure'
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' import { getUriWithOrg } from '@services/config/config';
import PageLoading from '@components/Objects/Loaders/PageLoading'; import React from 'react'
import ClientComponentSkeleton from '@components/Utils/ClientComp'; import { CourseProvider } from '../../../../../../../../components/Contexts/CourseContext';
import { getAPIUrl, getUriWithOrg } from '@services/config/config';
import { swrFetcher } from '@services/utils/ts/requests';
import React, { createContext, use, useEffect, useState } from 'react'
import useSWR from 'swr';
import { CourseProvider, useCourse } from '../../../../../../../../components/Contexts/CourseContext';
import SaveState from '@components/Dashboard/UI/SaveState';
import Link from 'next/link'; import Link from 'next/link';
import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop'; import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop';
import { CSSTransition } from 'react-transition-group';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral'; import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral';
import { GalleryVertical, GalleryVerticalEnd, Info } from 'lucide-react'; import { GalleryVerticalEnd, Info } from 'lucide-react';
export type CourseOverviewParams = { export type CourseOverviewParams = {
orgslug: string, orgslug: string,
@ -32,9 +25,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
} }
return ( return (
<div className='h-full w-full bg-[#f8f8f8]'> <div className='h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto,1fr]'>
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}> <CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}>
<div className='pl-10 pr-10 tracking-tight bg-[#fcfbfc] shadow-[0px_4px_16px_rgba(0,0,0,0.02)]'> <div className='pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]'>
<CourseOverviewTop params={params} /> <CourseOverviewTop params={params} />
<div className='flex space-x-5 font-black text-sm'> <div className='flex space-x-5 font-black text-sm'>
<Link href={getUriWithOrg(params.orgslug, "") + `/dash/courses/course/${params.courseuuid}/general`}> <Link href={getUriWithOrg(params.orgslug, "") + `/dash/courses/course/${params.courseuuid}/general`}>
@ -57,12 +50,12 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
</Link> </Link>
</div> </div>
</div> </div>
<div className='h-6'></div>
<motion.div <motion.div
initial={{ opacity: 0, }} initial={{ opacity: 0, }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.10, type: "spring", stiffness: 80 }} transition={{ duration: 0.10, type: "spring", stiffness: 80 }}
className='h-full overflow-y-auto'
> >
{params.subpage == 'content' ? <EditCourseStructure orgslug={params.orgslug} /> : ''} {params.subpage == 'content' ? <EditCourseStructure orgslug={params.orgslug} /> : ''}
{params.subpage == 'general' ? <EditCourseGeneral orgslug={params.orgslug} /> : ''} {params.subpage == 'general' ? <EditCourseGeneral orgslug={params.orgslug} /> : ''}

View file

@ -1,17 +1,20 @@
import SessionProvider from '@components/Contexts/SessionContext' import SessionProvider from '@components/Contexts/SessionContext'
import LeftMenu from '@components/Dashboard/UI/LeftMenu' import LeftMenu from '@components/Dashboard/UI/LeftMenu'
import AdminAuthorization from '@components/Security/AdminAuthorization'
import React from 'react' import React from 'react'
function DashboardLayout({ children, params }: { children: React.ReactNode, params: any }) { function DashboardLayout({ children, params }: { children: React.ReactNode, params: any }) {
return ( return (
<> <>
<SessionProvider> <SessionProvider>
<div className='flex'> <AdminAuthorization authorizationMode="page">
<LeftMenu/> <div className='flex'>
<div className='flex w-full'> <LeftMenu />
{children} <div className='flex w-full'>
</div> {children}
</div> </div>
</div>
</AdminAuthorization>
</SessionProvider> </SessionProvider>
</> </>
) )

View file

@ -1,11 +1,62 @@
import PageLoading from '@components/Objects/Loaders/PageLoading' import Image from 'next/image'
import React from 'react' import React from 'react'
import learnhousetextlogo from '../../../../public/learnhouse_logo.png'
import { BookCopy, School, Settings, Users } from 'lucide-react'
import Link from 'next/link'
import AdminAuthorization from '@components/Security/AdminAuthorization'
function DashboardHome() { function DashboardHome() {
return ( return (
<div className="flex items-center justify-center mx-auto min-h-screen flex-col space-x-3"> <div className="flex items-center justify-center mx-auto min-h-screen flex-col space-x-3">
<PageLoading /> <div className='mx-auto pb-10'>
<div className='text-neutral-400 font-bold animate-pulse text-2xl'>This page is work in progress</div> <Image alt='learnhouse logo' width={230} src={learnhousetextlogo}></Image>
</div>
<AdminAuthorization authorizationMode="component">
<div className='flex space-x-10'>
<Link href={`/dash/courses`} className='flex bg-white shadow-lg p-[35px] w-[250px] rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'>
<div className='flex flex-col mx-auto space-y-2'>
<BookCopy className='mx-auto text-gray-500' size={50}></BookCopy>
<div className='text-center font-bold text-gray-500'>Courses</div>
<p className='text-center text-sm text-gray-400'>Create and manage courses, chapters and ativities </p>
</div>
</Link>
<Link href={`/dash/org/settings/general`} className='flex bg-white shadow-lg p-[35px] w-[250px] rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'>
<div className='flex flex-col mx-auto space-y-2'>
<School className='mx-auto text-gray-500' size={50}></School>
<div className='text-center font-bold text-gray-500'>Organization</div>
<p className='text-center text-sm text-gray-400'>Configure your Organization general settings </p>
</div>
</Link>
<Link href={`/dash/users/settings/users`} className='flex bg-white shadow-lg p-[35px] w-[250px] rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'>
<div className='flex flex-col mx-auto space-y-2'>
<Users className='mx-auto text-gray-500' size={50}></Users>
<div className='text-center font-bold text-gray-500'>Users</div>
<p className='text-center text-sm text-gray-400'>Manage your Organization's users, roles </p>
</div>
</Link>
</div>
</AdminAuthorization>
<div className='flex flex-col space-y-10 '>
<AdminAuthorization authorizationMode="component">
<div className='h-1 w-[100px] bg-neutral-200 rounded-full mx-auto'></div>
<div className="flex justify-center items-center">
<Link href={'https://learn.learnhouse.io/'} className='flex mt-[40px] bg-black space-x-2 items-center py-3 px-7 rounded-lg shadow-lg hover:scale-105 transition-all ease-linear cursor-pointer'>
<BookCopy className=' text-gray-100' size={20}></BookCopy>
<div className=' text-sm font-bold text-gray-100'>Learn LearnHouse</div>
</Link>
</div>
<div className='mx-auto mt-[40px] w-28 h-1 bg-neutral-200 rounded-full'></div>
</AdminAuthorization>
<Link href={'/dash/user-account/settings/general'} className='flex bg-white shadow-lg p-[15px] items-center rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'>
<div className='flex flex-row mx-auto space-x-3 items-center'>
<Settings className=' text-gray-500' size={20}></Settings>
<div className=' font-bold text-gray-500'>Account Settings</div>
<p className=' text-sm text-gray-400'>Configure your personal settings, passwords, email</p>
</div>
</Link>
</div>
</div> </div>
) )
} }

View file

@ -1,8 +1,8 @@
'use client'; 'use client';
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import UserEditGeneral from '@components/Dashboard/User/UserEditGeneral/UserEditGeneral'; import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral';
import UserEditPassword from '@components/Dashboard/User/UserEditPassword/UserEditPassword'; import UserEditPassword from '@components/Dashboard/UserAccount/UserEditPassword/UserEditPassword';
import Link from 'next/link'; import Link from 'next/link';
import { getUriWithOrg } from '@services/config/config'; import { getUriWithOrg } from '@services/config/config';
import { Info, Lock } from 'lucide-react'; import { Info, Lock } from 'lucide-react';
@ -24,7 +24,7 @@ function SettingsPage({ params }: { params: SettingsParams }) {
return ( return (
<div className='h-full w-full bg-[#f8f8f8]'> <div className='h-full w-full bg-[#f8f8f8]'>
<div className='pl-10 pr-10 tracking-tight bg-[#fcfbfc] shadow-[0px_4px_16px_rgba(0,0,0,0.02)]'> <div className='pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]'>
<BreadCrumbs type='user' last_breadcrumb={session?.user?.username} ></BreadCrumbs> <BreadCrumbs type='user' last_breadcrumb={session?.user?.username} ></BreadCrumbs>
<div className='my-2 tracking-tighter'> <div className='my-2 tracking-tighter'>
<div className='w-100 flex justify-between'> <div className='w-100 flex justify-between'>
@ -32,7 +32,7 @@ function SettingsPage({ params }: { params: SettingsParams }) {
</div> </div>
</div> </div>
<div className='flex space-x-5 font-black text-sm'> <div className='flex space-x-5 font-black text-sm'>
<Link href={getUriWithOrg(params.orgslug, "") + `/dash/user/settings/general`}> <Link href={getUriWithOrg(params.orgslug, "") + `/dash/user-account/settings/general`}>
<div className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'general' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}> <div className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'general' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>
<div className='flex items-center space-x-2.5 mx-2'> <div className='flex items-center space-x-2.5 mx-2'>
@ -41,7 +41,7 @@ function SettingsPage({ params }: { params: SettingsParams }) {
</div> </div>
</div> </div>
</Link> </Link>
<Link href={getUriWithOrg(params.orgslug, "") + `/dash/user/settings/security`}> <Link href={getUriWithOrg(params.orgslug, "") + `/dash/user-account/settings/security`}>
<div className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'security' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}> <div className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'security' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>
<div className='flex items-center space-x-2.5 mx-2'> <div className='flex items-center space-x-2.5 mx-2'>
<Lock size={16} /> <Lock size={16} />
@ -58,6 +58,7 @@ function SettingsPage({ params }: { params: SettingsParams }) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.10, type: "spring", stiffness: 80 }} transition={{ duration: 0.10, type: "spring", stiffness: 80 }}
className='h-full overflow-y-auto'
> >
{params.subpage == 'general' ? <UserEditGeneral /> : ''} {params.subpage == 'general' ? <UserEditGeneral /> : ''}
{params.subpage == 'security' ? <UserEditPassword /> : ''} {params.subpage == 'security' ? <UserEditPassword /> : ''}

View file

@ -0,0 +1,101 @@
'use client';
import React, { useEffect } from 'react'
import { motion } from 'framer-motion';
import Link from 'next/link';
import { getUriWithOrg } from '@services/config/config';
import { ScanEye, UserPlus, Users } from 'lucide-react';
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs';
import { useSession } from '@components/Contexts/SessionContext';
import { useOrg } from '@components/Contexts/OrgContext';
import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers';
import OrgAccess from '@components/Dashboard/Users/OrgAccess/OrgAccess';
export type SettingsParams = {
subpage: string
orgslug: string
}
function UsersSettingsPage({ params }: { params: SettingsParams }) {
const session = useSession() as any;
const org = useOrg() as any;
const [H1Label, setH1Label] = React.useState('')
const [H2Label, setH2Label] = React.useState('')
function handleLabels() {
if (params.subpage == 'users') {
setH1Label('Users')
setH2Label('Manage your organization users, assign roles and permissions')
}
if (params.subpage == 'signups') {
setH1Label('Signup Access')
setH2Label('Choose from where users can join your organization')
}
if (params.subpage == 'add') {
setH1Label('Invite users')
setH2Label('Invite users to join your organization')
}
}
useEffect(() => {
handleLabels()
}
, [session, org, params.subpage, params])
return (
<div className='h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto,1fr]'>
<div className='pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]'>
<BreadCrumbs type='orgusers' ></BreadCrumbs>
<div className='my-2 py-3'>
<div className='w-100 flex flex-col space-y-1'>
<div className='pt-3 flex font-bold text-4xl tracking-tighter'>{H1Label}</div>
<div className='flex font-medium text-gray-400 text-md'>{H2Label} </div>
</div>
</div>
<div className='flex space-x-5 font-black text-sm'>
<Link href={getUriWithOrg(params.orgslug, "") + `/dash/users/settings/users`}>
<div className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'users' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>
<div className='flex items-center space-x-2.5 mx-2'>
<Users size={16} />
<div>Users</div>
</div>
</div>
</Link>
<Link href={getUriWithOrg(params.orgslug, "") + `/dash/users/settings/add`}>
<div className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'add' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>
<div className='flex items-center space-x-2.5 mx-2'>
<UserPlus size={16} />
<div>Invite users</div>
</div>
</div>
</Link>
<Link href={getUriWithOrg(params.orgslug, "") + `/dash/users/settings/signups`}>
<div className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'signups' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>
<div className='flex items-center space-x-2.5 mx-2'>
<ScanEye size={16} />
<div>Signup Access</div>
</div>
</div>
</Link>
</div>
</div>
<motion.div
initial={{ opacity: 0, }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.10, type: "spring", stiffness: 80 }}
className='h-full overflow-y-auto'
>
{params.subpage == 'users' ? <OrgUsers /> : ''}
{params.subpage == 'signups' ? <OrgAccess /> : ''}
</motion.div>
</div>
)
}
export default UsersSettingsPage

View file

@ -1,11 +1,10 @@
"use client"; "use client";;
import learnhouseIcon from "public/learnhouse_bigicon_1.png"; import learnhouseIcon from "public/learnhouse_bigicon_1.png";
import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input } from '@components/StyledElements/Form/Form' import FormLayout, { FormField, FormLabelAndMessage, Input } from '@components/StyledElements/Form/Form';
import Image from 'next/image'; import Image from 'next/image';
import * as Form from '@radix-ui/react-form'; import * as Form from '@radix-ui/react-form';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { getOrgLogoMediaDirectory } from "@services/media/media"; import { getOrgLogoMediaDirectory } from "@services/media/media";
import { BarLoader } from "react-spinners";
import React from "react"; import React from "react";
import { loginAndGetToken } from "@services/auth/auth"; import { loginAndGetToken } from "@services/auth/auth";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
@ -79,14 +78,14 @@ const LoginClient = (props: LoginClientProps) => {
<div className="m-auto flex space-x-4 items-center flex-wrap"> <div className="m-auto flex space-x-4 items-center flex-wrap">
<div>Login to </div> <div>Login to </div>
<div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]" > <div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]" >
{props.org?.logo ? ( {props.org?.logo_image ? (
<img <img
src={`${getOrgLogoMediaDirectory(props.org.org_id, props.org?.logo)}`} src={`${getOrgLogoMediaDirectory(props.org.org_uuid, props.org?.logo_image)}`}
alt="Learnhouse" alt="Learnhouse"
style={{ width: "auto", height: 70 }} style={{ width: "auto", height: 70 }}
className="rounded-md shadow-xl inset-0 ring-1 ring-inset ring-black/10 bg-white" className="rounded-xl shadow-xl inset-0 ring-1 ring-inset ring-black/10 bg-white"
/> />
) : ( ) : (
<Image quality={100} width={70} height={70} src={learnhouseIcon} alt="" /> <Image quality={100} width={70} height={70} src={learnhouseIcon} alt="" />
)} )}
</div> </div>

View file

@ -12,7 +12,7 @@ export async function generateMetadata(
): Promise<Metadata> { ): Promise<Metadata> {
const orgslug = params.orgslug; const orgslug = params.orgslug;
// Get Org context information // Get Org context information
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] }); const org = await getOrganizationContextInfo(orgslug, { revalidate: 0, tags: ['organizations'] });
return { return {
title: 'Login' + `${org.name}`, title: 'Login' + `${org.name}`,
@ -21,7 +21,7 @@ export async function generateMetadata(
const Login = async (params: any) => { const Login = async (params: any) => {
const orgslug = params.params.orgslug; const orgslug = params.params.orgslug;
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] }); const org = await getOrganizationContextInfo(orgslug, { revalidate: 0, tags: ['organizations'] });
return ( return (
<div> <div>

View file

@ -0,0 +1,166 @@
"use client";
import { useFormik } from 'formik';
import { useRouter } from 'next/navigation';
import React, { useEffect } from 'react'
import FormLayout, { FormField, FormLabelAndMessage, Input, Textarea } from '@components/StyledElements/Form/Form';
import * as Form from '@radix-ui/react-form';
import { AlertTriangle, Check, User } from 'lucide-react';
import Link from 'next/link';
import { signUpWithInviteCode } from '@services/auth/auth';
import { useOrg } from '@components/Contexts/OrgContext';
const validate = (values: any) => {
const errors: any = {};
if (!values.email) {
errors.email = 'Required';
}
else if (
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)
) {
errors.email = 'Invalid email address';
}
if (!values.password) {
errors.password = 'Required';
}
else if (values.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (!values.username) {
errors.username = 'Required';
}
if (!values.username || values.username.length < 4) {
errors.username = 'Username must be at least 4 characters';
}
if (!values.bio) {
errors.bio = 'Required';
}
return errors;
};
interface InviteOnlySignUpProps {
inviteCode: string;
}
function InviteOnlySignUpComponent(props : InviteOnlySignUpProps) {
const [isSubmitting, setIsSubmitting] = React.useState(false);
const org = useOrg() as any;
const router = useRouter();
const [error, setError] = React.useState('');
const [message, setMessage] = React.useState('');
const formik = useFormik({
initialValues: {
org_slug: org?.slug,
org_id: org?.id,
email: '',
password: '',
username: '',
bio: '',
first_name: '',
last_name: '',
},
validate,
onSubmit: async values => {
setError('')
setMessage('')
setIsSubmitting(true);
let res = await signUpWithInviteCode(values, props.inviteCode);
let message = await res.json();
if (res.status == 200) {
//router.push(`/login`);
setMessage('Your account was successfully created')
setIsSubmitting(false);
}
else if (res.status == 401 || res.status == 400 || res.status == 404 || res.status == 409) {
setError(message.detail);
setIsSubmitting(false);
}
else {
setError("Something went wrong");
setIsSubmitting(false);
}
},
});
useEffect(() => {
}
, [org]);
return (
<div className="login-form m-auto w-72">
{error && (
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm">
<AlertTriangle size={18} />
<div className="font-bold text-sm">{error}</div>
</div>
)}
{message && (
<div className="flex flex-col space-y-4 justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<div className='flex space-x-2'>
<Check size={18} />
<div className="font-bold text-sm">{message}</div>
</div>
<hr className='border-green-900/20 800 w-40 border' />
<Link className='flex space-x-2 items-center' href={'/login'}><User size={14} /> <div>Login </div></Link>
</div>
)}
<FormLayout onSubmit={formik.handleSubmit}>
<FormField name="email">
<FormLabelAndMessage label='Email' message={formik.errors.email} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.email} type="email" required />
</Form.Control>
</FormField>
{/* for password */}
<FormField name="password">
<FormLabelAndMessage label='Password' message={formik.errors.password} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.password} type="password" required />
</Form.Control>
</FormField>
{/* for username */}
<FormField name="username">
<FormLabelAndMessage label='Username' message={formik.errors.username} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.username} type="text" required />
</Form.Control>
</FormField>
{/* for bio */}
<FormField name="bio">
<FormLabelAndMessage label='Bio' message={formik.errors.bio} />
<Form.Control asChild>
<Textarea onChange={formik.handleChange} value={formik.values.bio} required />
</Form.Control>
</FormField>
<div className="flex py-4">
<Form.Submit asChild>
<button className="w-full bg-black text-white font-bold text-center p-2 rounded-md shadow-md hover:cursor-pointer" >
{isSubmitting ? "Loading..."
: "Create an account & Join"}
</button>
</Form.Submit>
</div>
</FormLayout>
</div>
)
}
export default InviteOnlySignUpComponent

View file

@ -0,0 +1,163 @@
"use client";
import { useFormik } from 'formik';
import { useRouter } from 'next/navigation';
import React, { useEffect } from 'react'
import FormLayout, { FormField, FormLabelAndMessage, Input, Textarea } from '@components/StyledElements/Form/Form';
import * as Form from '@radix-ui/react-form';
import { AlertTriangle, Check, User } from 'lucide-react';
import Link from 'next/link';
import { signup } from '@services/auth/auth';
import { useOrg } from '@components/Contexts/OrgContext';
const validate = (values: any) => {
const errors: any = {};
if (!values.email) {
errors.email = 'Required';
}
else if (
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)
) {
errors.email = 'Invalid email address';
}
if (!values.password) {
errors.password = 'Required';
}
else if (values.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (!values.username) {
errors.username = 'Required';
}
if (!values.username || values.username.length < 4) {
errors.username = 'Username must be at least 4 characters';
}
if (!values.bio) {
errors.bio = 'Required';
}
return errors;
};
function OpenSignUpComponent() {
const [isSubmitting, setIsSubmitting] = React.useState(false);
const org = useOrg() as any;
const router = useRouter();
const [error, setError] = React.useState('');
const [message, setMessage] = React.useState('');
const formik = useFormik({
initialValues: {
org_slug: org?.slug,
org_id: org?.id,
email: '',
password: '',
username: '',
bio: '',
first_name: '',
last_name: '',
},
validate,
onSubmit: async values => {
setError('')
setMessage('')
setIsSubmitting(true);
let res = await signup(values);
let message = await res.json();
if (res.status == 200) {
//router.push(`/login`);
setMessage('Your account was successfully created')
setIsSubmitting(false);
}
else if (res.status == 401 || res.status == 400 || res.status == 404 || res.status == 409) {
setError(message.detail);
setIsSubmitting(false);
}
else {
setError("Something went wrong");
setIsSubmitting(false);
}
},
});
useEffect(() => {
}
, [org]);
return (
<div className="login-form m-auto w-72">
{error && (
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm">
<AlertTriangle size={18} />
<div className="font-bold text-sm">{error}</div>
</div>
)}
{message && (
<div className="flex flex-col space-y-4 justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<div className='flex space-x-2'>
<Check size={18} />
<div className="font-bold text-sm">{message}</div>
</div>
<hr className='border-green-900/20 800 w-40 border' />
<Link className='flex space-x-2 items-center' href={'/login'}><User size={14} /> <div>Login </div></Link>
</div>
)}
<FormLayout onSubmit={formik.handleSubmit}>
<FormField name="email">
<FormLabelAndMessage label='Email' message={formik.errors.email} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.email} type="email" required />
</Form.Control>
</FormField>
{/* for password */}
<FormField name="password">
<FormLabelAndMessage label='Password' message={formik.errors.password} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.password} type="password" required />
</Form.Control>
</FormField>
{/* for username */}
<FormField name="username">
<FormLabelAndMessage label='Username' message={formik.errors.username} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.username} type="text" required />
</Form.Control>
</FormField>
{/* for bio */}
<FormField name="bio">
<FormLabelAndMessage label='Bio' message={formik.errors.bio} />
<Form.Control asChild>
<Textarea onChange={formik.handleChange} value={formik.values.bio} required />
</Form.Control>
</FormField>
<div className="flex py-4">
<Form.Submit asChild>
<button className="w-full bg-black text-white font-bold text-center p-2 rounded-md shadow-md hover:cursor-pointer" >
{isSubmitting ? "Loading..."
: "Create an account"}
</button>
</Form.Submit>
</div>
</FormLayout>
</div>
)
}
export default OpenSignUpComponent

View file

@ -1,8 +1,9 @@
import React from "react";
import SignUpClient from "./signup";
import { Metadata } from "next"; import { Metadata } from "next";
import { getOrganizationContextInfo } from "@services/organizations/orgs"; import { getOrganizationContextInfo } from "@services/organizations/orgs";
import SignUpClient from "./signup";
import { Suspense } from "react";
import PageLoading from "@components/Objects/Loaders/PageLoading";
type MetadataProps = { type MetadataProps = {
params: { orgslug: string, courseid: string }; params: { orgslug: string, courseid: string };
@ -14,7 +15,7 @@ export async function generateMetadata(
): Promise<Metadata> { ): Promise<Metadata> {
const orgslug = params.orgslug; const orgslug = params.orgslug;
// Get Org context information // Get Org context information
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] }); const org = await getOrganizationContextInfo(orgslug, { revalidate: 0, tags: ['organizations'] });
return { return {
title: 'Sign up' + `${org.name}`, title: 'Sign up' + `${org.name}`,
@ -23,12 +24,14 @@ export async function generateMetadata(
const SignUp = async (params: any) => { const SignUp = async (params: any) => {
const orgslug = params.params.orgslug; const orgslug = params.params.orgslug;
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] }); const org = await getOrganizationContextInfo(orgslug, { revalidate: 0, tags: ['organizations'] });
return ( return (
<div> <>
<SignUpClient org={org}></SignUpClient> <Suspense fallback={<PageLoading/>}>
</div> <SignUpClient org={org} />
</Suspense>
</>
); );
}; };
export default SignUp; export default SignUp;

View file

@ -1,117 +1,62 @@
"use client"; "use client";
import { useFormik } from 'formik';
import { useRouter } from 'next/navigation';
import learnhouseIcon from "public/learnhouse_bigicon_1.png"; import learnhouseIcon from "public/learnhouse_bigicon_1.png";
import React from 'react'
import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input, Textarea } from '@components/StyledElements/Form/Form'
import Image from 'next/image'; import Image from 'next/image';
import * as Form from '@radix-ui/react-form';
import { getOrgLogoMediaDirectory } from '@services/media/media'; import { getOrgLogoMediaDirectory } from '@services/media/media';
import { AlertTriangle, Check, User } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { signup } from '@services/auth/auth';
import { getUriWithOrg } from '@services/config/config'; import { getUriWithOrg } from '@services/config/config';
import { useSession } from "@components/Contexts/SessionContext";
import React, { useEffect } from "react";
import { MailWarning, Shield, UserPlus } from "lucide-react";
import { useOrg } from "@components/Contexts/OrgContext";
import UserAvatar from "@components/Objects/UserAvatar";
import OpenSignUpComponent from "./OpenSignup";
import InviteOnlySignUpComponent from "./InviteOnlySignUp";
import { useRouter, useSearchParams } from "next/navigation";
import { validateInviteCode } from "@services/organizations/invites";
import PageLoading from "@components/Objects/Loaders/PageLoading";
import Toast from "@components/StyledElements/Toast/Toast";
import toast from "react-hot-toast";
interface SignUpClientProps { interface SignUpClientProps {
org: any; org: any;
} }
const validate = (values: any) => {
const errors: any = {};
if (!values.email) {
errors.email = 'Required';
}
else if (
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)
) {
errors.email = 'Invalid email address';
}
if (!values.password) {
errors.password = 'Required';
}
else if (values.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (!values.username) {
errors.username = 'Required';
}
if (!values.username || values.username.length < 4) {
errors.username = 'Username must be at least 4 characters';
}
if (!values.bio) {
errors.bio = 'Required';
}
return errors;
};
function SignUpClient(props: SignUpClientProps) { function SignUpClient(props: SignUpClientProps) {
const [isSubmitting, setIsSubmitting] = React.useState(false); const session = useSession() as any;
const router = useRouter(); const [joinMethod, setJoinMethod] = React.useState('open');
const [error, setError] = React.useState(''); const [inviteCode, setInviteCode] = React.useState('');
const [message, setMessage] = React.useState(''); const searchParams = useSearchParams()
const formik = useFormik({ const inviteCodeParam = searchParams.get('inviteCode')
initialValues: {
org_slug: props.org?.slug,
org_id: props.org?.id,
email: '',
password: '',
username: '',
bio: '',
first_name: '',
last_name: '',
},
validate,
onSubmit: async values => {
setError('')
setMessage('')
setIsSubmitting(true);
let res = await signup(values);
let message = await res.json();
if (res.status == 200) {
//router.push(`/login`);
setMessage('Your account was successfully created')
setIsSubmitting(false);
}
else if (res.status == 401 || res.status == 400 || res.status == 404 || res.status == 409) {
setError(message.detail);
setIsSubmitting(false);
}
else {
setError("Something went wrong");
setIsSubmitting(false);
}
}, useEffect(() => {
}); if (props.org.config) {
setJoinMethod(props.org?.config?.config?.GeneralConfig.users.signup_mechanism);
console.log(props.org?.config?.config?.GeneralConfig.users.signup_mechanism)
}
if (inviteCodeParam) {
setInviteCode(inviteCodeParam);
}
}
, [props.org, inviteCodeParam]);
return ( return (
<div><div className='grid grid-flow-col justify-stretch h-screen'> <div className='grid grid-flow-col justify-stretch h-screen'>
<div className="right-login-part" style={{ background: "linear-gradient(041.61deg, #202020 7.15%, #000000 90.96%)" }} > <div className="right-login-part" style={{ background: "linear-gradient(041.61deg, #202020 7.15%, #000000 90.96%)" }} >
<div className='login-topbar m-10'> <div className='login-topbar m-10'>
<Link prefetch href={getUriWithOrg(props.org.slug, "/")}> <Link prefetch href={getUriWithOrg(props.org.slug, "/")}>
<Image quality={100} width={30} height={30} src={learnhouseIcon} alt="" /> <Image quality={100} width={30} height={30} src={learnhouseIcon} alt="" />
</Link> </Link>
</div> </div>
<div className="ml-10 h-4/6 flex flex-row text-white"> <div className="ml-10 h-3/4 flex flex-row text-white">
<div className="m-auto flex space-x-4 items-center flex-wrap"> <div className="m-auto flex space-x-4 items-center flex-wrap">
<div>Join </div> <div>You've been invited to join </div>
<div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]" > <div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]" >
{props.org?.logo ? ( {props.org?.logo_image ? (
<img <img
src={`${getOrgLogoMediaDirectory(props.org.org_id, props.org?.logo)}`} src={`${getOrgLogoMediaDirectory(props.org.org_uuid, props.org?.logo_image)}`}
alt="Learnhouse" alt="Learnhouse"
style={{ width: "auto", height: 70 }} style={{ width: "auto", height: 70 }}
className="rounded-md shadow-xl inset-0 ring-1 ring-inset ring-black/10 bg-white" className="rounded-xl shadow-xl inset-0 ring-1 ring-inset ring-black/10 bg-white"
/> />
) : ( ) : (
<Image quality={100} width={70} height={70} src={learnhouseIcon} alt="" /> <Image quality={100} width={70} height={70} src={learnhouseIcon} alt="" />
@ -121,70 +66,113 @@ function SignUpClient(props: SignUpClientProps) {
</div> </div>
</div> </div>
</div> </div>
<div className="left-login-part bg-white flex flex-row"> <div className="left-join-part bg-white flex flex-row">
<div className="login-form m-auto w-72"> {joinMethod == 'open' && (
{error && ( session.isAuthenticated ? <LoggedInJoinScreen inviteCode={inviteCode} /> : <OpenSignUpComponent />
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm"> )}
<AlertTriangle size={18} /> {joinMethod == 'inviteOnly' && (
<div className="font-bold text-sm">{error}</div> inviteCode ? (
</div> session.isAuthenticated ? <LoggedInJoinScreen /> : <InviteOnlySignUpComponent inviteCode={inviteCode} />
)} ) : <NoTokenScreen />
{message && ( )}
<div className="flex flex-col space-y-4 justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<div className='flex space-x-2'>
<Check size={18} />
<div className="font-bold text-sm">{message}</div>
</div>
<hr className='border-green-900/20 800 w-40 border' />
<Link className='flex space-x-2 items-center' href={'/login'}><User size={14} /> <div>Login </div></Link>
</div>
)}
<FormLayout onSubmit={formik.handleSubmit}>
<FormField name="email">
<FormLabelAndMessage label='Email' message={formik.errors.email} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.email} type="email" required />
</Form.Control>
</FormField>
{/* for password */}
<FormField name="password">
<FormLabelAndMessage label='Password' message={formik.errors.password} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.password} type="password" required />
</Form.Control>
</FormField>
{/* for username */}
<FormField name="username">
<FormLabelAndMessage label='Username' message={formik.errors.username} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.username} type="text" required />
</Form.Control>
</FormField>
{/* for bio */}
<FormField name="bio">
<FormLabelAndMessage label='Bio' message={formik.errors.bio} />
<Form.Control asChild>
<Textarea onChange={formik.handleChange} value={formik.values.bio} required />
</Form.Control>
</FormField>
<div className="flex py-4">
<Form.Submit asChild>
<button className="w-full bg-black text-white font-bold text-center p-2 rounded-md shadow-md hover:cursor-pointer" >
{isSubmitting ? "Loading..."
: "Create an account"}
</button>
</Form.Submit>
</div>
</FormLayout>
</div>
</div> </div>
</div></div> </div>
)
}
const LoggedInJoinScreen = (props: any) => {
const session = useSession() as any;
const org = useOrg() as any;
const [isLoading, setIsLoading] = React.useState(true);
useEffect(() => {
if (session && org) {
setIsLoading(false);
}
}
, [org, session]);
return (
<div className="flex flex-row items-center mx-auto">
<div className="flex space-y-7 flex-col justify-center items-center">
<p className='pt-3 text-2xl font-semibold text-black/70 flex justify-center space-x-2 items-center'>
<span className='items-center'>Hi</span>
<span className='capitalize flex space-x-2 items-center'>
<UserAvatar rounded='rounded-xl' border='border-4' width={35} />
<span>{session.user.username},</span>
</span>
<span>join {org?.name} ?</span>
</p>
<button className="flex w-fit space-x-2 bg-black px-6 py-2 text-md rounded-lg font-semibold h-fit text-white items-center shadow-md">
<UserPlus size={18} />
<p>Join </p>
</button>
</div>
</div>
)
}
const NoTokenScreen = (props: any) => {
const session = useSession() as any;
const org = useOrg() as any;
const router = useRouter();
const [isLoading, setIsLoading] = React.useState(true);
const [inviteCode, setInviteCode] = React.useState('');
const [messsage, setMessage] = React.useState('bruh');
const handleInviteCodeChange = (e: any) => {
setInviteCode(e.target.value);
}
const validateCode = async () => {
setIsLoading(true);
let res = await validateInviteCode(org?.id, inviteCode);
//wait for 1s
if (res.success) {
toast.success("Invite code is valid, you'll be redirected to the signup page in a few seconds");
setTimeout(() => {
router.push(`/signup?inviteCode=${inviteCode}`);
}, 2000);
}
else {
toast.error("Invite code is invalid");
setIsLoading(false);
}
}
useEffect(() => {
if (session && org) {
setIsLoading(false);
}
}
, [org, session]);
return (
<div className="flex flex-row items-center mx-auto">
<Toast />
{isLoading ? <div className="flex space-y-7 flex-col w-[300px] justify-center items-center"><PageLoading /></div> : <div className="flex space-y-7 flex-col justify-center items-center">
<p className="flex space-x-2 text-lg font-medium text-red-800 items-center">
<MailWarning size={18} />
<span>An invite code is required to join {org?.name}</span>
</p>
<input onChange={handleInviteCodeChange} className="bg-white outline-2 outline outline-gray-200 rounded-lg px-5 w-[300px] h-[50px]" placeholder="Please enter an invite code" type="text" />
<button onClick={validateCode} className="flex w-fit space-x-2 bg-black px-6 py-2 text-md rounded-lg font-semibold h-fit text-white items-center shadow-md">
<Shield size={18} />
<p>Submit </p>
</button>
</div>}
</div>
) )
} }

View file

@ -1,5 +1,4 @@
"use client"; "use client";
import type { NextPage } from "next";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import styled from "styled-components"; import styled from "styled-components";
import learnhouseBigIcon from "public/learnhouse_bigicon.png"; import learnhouseBigIcon from "public/learnhouse_bigicon.png";

View file

@ -3,7 +3,7 @@ import PageLoading from '@components/Objects/Loaders/PageLoading';
import { getAPIUrl } from '@services/config/config'; import { getAPIUrl } from '@services/config/config';
import { swrFetcher } from '@services/utils/ts/requests'; import { swrFetcher } from '@services/utils/ts/requests';
import React, { createContext, useContext, useEffect, useReducer } from 'react' import React, { createContext, useContext, useEffect, useReducer } from 'react'
import useSWR, { mutate } from 'swr'; import useSWR from 'swr';
export const CourseContext = createContext(null) as any; export const CourseContext = createContext(null) as any;
export const CourseDispatchContext = createContext(null) as any; export const CourseDispatchContext = createContext(null) as any;

View file

@ -1,13 +1,9 @@
'use client'; 'use client';
import { getNewAccessTokenUsingRefreshToken, getUserSession } from '@services/auth/auth'; import { getNewAccessTokenUsingRefreshToken, getUserSession } from '@services/auth/auth';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import React, { useContext, createContext, useEffect } from 'react' import React, { useContext, createContext, useEffect } from 'react'
import { useOrg } from './OrgContext';
export const SessionContext = createContext({}) as any; export const SessionContext = createContext({}) as any;
const PATHS_THAT_REQUIRE_AUTH = ['/dash'];
type Session = { type Session = {
access_token: string; access_token: string;
user: any; user: any;
@ -18,10 +14,6 @@ type Session = {
function SessionProvider({ children }: { children: React.ReactNode }) { function SessionProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = React.useState<Session>({ access_token: "", user: {}, roles: {}, isLoading: true, isAuthenticated: false }); const [session, setSession] = React.useState<Session>({ access_token: "", user: {}, roles: {}, isLoading: true, isAuthenticated: false });
const org = useOrg() as any;
const pathname = usePathname()
const router = useRouter()
async function getNewAccessTokenUsingRefreshTokenUI() { async function getNewAccessTokenUsingRefreshTokenUI() {
let data = await getNewAccessTokenUsingRefreshToken(); let data = await getNewAccessTokenUsingRefreshToken();
@ -39,6 +31,10 @@ function SessionProvider({ children }: { children: React.ReactNode }) {
// Set session // Set session
setSession({ access_token: access_token, user: user_session.user, roles: user_session.roles, isLoading: false, isAuthenticated: true }); setSession({ access_token: access_token, user: user_session.user, roles: user_session.roles, isLoading: false, isAuthenticated: true });
} }
if (!access_token) {
setSession({ access_token: "", user: {}, roles: {}, isLoading: false, isAuthenticated: false });
}
} }
@ -47,8 +43,6 @@ function SessionProvider({ children }: { children: React.ReactNode }) {
// Check session // Check session
checkSession(); checkSession();
}, []) }, [])
return ( return (

View file

@ -5,6 +5,7 @@ import * as Switch from '@radix-ui/react-switch';
import * as Form from '@radix-ui/react-form'; import * as Form from '@radix-ui/react-form';
import React from 'react' import React from 'react'
import { useCourse, useCourseDispatch } from '../../../Contexts/CourseContext'; import { useCourse, useCourseDispatch } from '../../../Contexts/CourseContext';
import ThumbnailUpdate from './ThumbnailUpdate';
type EditCourseStructureProps = { type EditCourseStructureProps = {
@ -84,71 +85,80 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
}, [course, formik.values, formik.initialValues]); }, [course, formik.values, formik.initialValues]);
return ( return (
<div className='ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-6 py-5'> <div> <div className="h-6"></div>
<div className='ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-6 py-5'>
{course.courseStructure && ( {course.courseStructure && (
<div className="editcourse-form"> <div className="editcourse-form">
{error && ( {error && (
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm"> <div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm">
<AlertTriangle size={18} /> <AlertTriangle size={18} />
<div className="font-bold text-sm">{error}</div> <div className="font-bold text-sm">{error}</div>
</div>
)}
<FormLayout onSubmit={formik.handleSubmit}>
<FormField name="name">
<FormLabelAndMessage label='Name' message={formik.errors.name} />
<Form.Control asChild>
<Input style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.name} type="text" required />
</Form.Control>
</FormField>
<FormField name="description">
<FormLabelAndMessage label='Description' message={formik.errors.description} />
<Form.Control asChild>
<Textarea style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.description} required />
</Form.Control>
</FormField>
<FormField name="about">
<FormLabelAndMessage label='About' message={formik.errors.about} />
<Form.Control asChild>
<Textarea style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.about} required />
</Form.Control>
</FormField>
<FormField name="learnings">
<FormLabelAndMessage label='Learnings' message={formik.errors.learnings} />
<Form.Control asChild>
<Textarea style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.learnings} required />
</Form.Control>
</FormField>
<FormField name="tags">
<FormLabelAndMessage label='Tags' message={formik.errors.tags} />
<Form.Control asChild>
<Textarea style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.tags} required />
</Form.Control>
</FormField>
<FormField className="flex items-center h-10" name="public">
<div className='flex my-auto items-center'>
<label className="text-black text-[15px] leading-none pr-[15px]" htmlFor="public-course">
Public Course
</label>
<Switch.Root
className="w-[42px] h-[25px] bg-neutral-200 rounded-full relative data-[state=checked]:bg-neutral-500 outline-none cursor-default"
id="public-course"
onCheckedChange={checked => formik.setFieldValue('public', checked)}
checked={formik.values.public === 'true'}
>
<Switch.Thumb className="block w-[21px] h-[21px] bg-white rounded-full shadow-[0_2px_2px] shadow-neutral-300 transition-transform duration-100 translate-x-0.5 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div> </div>
</FormField> )}
<FormLayout onSubmit={formik.handleSubmit}>
<FormField name="name">
<FormLabelAndMessage label='Name' message={formik.errors.name} />
<Form.Control asChild>
<Input style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.name} type="text" required />
</Form.Control>
</FormField>
</FormLayout> <FormField name="description">
</div> <FormLabelAndMessage label='Description' message={formik.errors.description} />
)} <Form.Control asChild>
<Textarea style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.description} required />
</Form.Control>
</FormField>
<FormField name="about">
<FormLabelAndMessage label='About' message={formik.errors.about} />
<Form.Control asChild>
<Textarea style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.about} required />
</Form.Control>
</FormField>
<FormField name="learnings">
<FormLabelAndMessage label='Learnings' message={formik.errors.learnings} />
<Form.Control asChild>
<Textarea style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.learnings} required />
</Form.Control>
</FormField>
<FormField name="tags">
<FormLabelAndMessage label='Tags' message={formik.errors.tags} />
<Form.Control asChild>
<Textarea style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.tags} required />
</Form.Control>
</FormField>
<FormField name="thumbnail">
<FormLabelAndMessage label='Thumbnail' />
<Form.Control asChild>
<ThumbnailUpdate />
</Form.Control>
</FormField>
<FormField className="flex items-center h-10" name="public">
<div className='flex my-auto items-center'>
<label className="text-black text-[15px] leading-none pr-[15px]" htmlFor="public-course">
Public Course
</label>
<Switch.Root
className="w-[42px] h-[25px] bg-neutral-200 rounded-full relative data-[state=checked]:bg-neutral-500 outline-none cursor-default"
id="public-course"
onCheckedChange={checked => formik.setFieldValue('public', checked)}
checked={formik.values.public === 'true'}
>
<Switch.Thumb className="block w-[21px] h-[21px] bg-white rounded-full shadow-[0_2px_2px] shadow-neutral-300 transition-transform duration-100 translate-x-0.5 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</FormField>
</FormLayout>
</div>
)}
</div>
</div> </div>
) )
} }

View file

@ -0,0 +1,79 @@
import { useCourse } from '@components/Contexts/CourseContext';
import { useOrg } from '@components/Contexts/OrgContext';
import { getAPIUrl } from '@services/config/config';
import { updateCourseThumbnail } from '@services/courses/courses';
import { getCourseThumbnailMediaDirectory } from '@services/media/media';
import { ArrowBigUpDash, UploadCloud } from 'lucide-react';
import React from 'react'
import { mutate } from 'swr';
function ThumbnailUpdate() {
const course = useCourse() as any;
const org = useOrg() as any;
const [localThumbnail, setLocalThumbnail] = React.useState(null) as any;
const [isLoading, setIsLoading] = React.useState(false) as any;
const [error, setError] = React.useState('') as any;
const handleFileChange = async (event: any) => {
const file = event.target.files[0];
setLocalThumbnail(file);
setIsLoading(true);
const res = await updateCourseThumbnail(course.courseStructure.course_uuid, file)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`);
// wait for 1 second to show loading animation
await new Promise(r => setTimeout(r, 1500));
if (res.success === false) {
setError(res.HTTPmessage);
} else {
setIsLoading(false);
setError('');
}
};
return (
<div className='w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow'>
<div className='flex flex-col justify-center items-center h-full'>
<div className='flex flex-col justify-center items-center'>
<div className='flex flex-col justify-center items-center'>
{error && (
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-2 transition-all shadow-sm">
<div className="text-sm font-semibold">{error}</div>
</div>
)}
{localThumbnail ? (
<img src={URL.createObjectURL(localThumbnail)} className={`${isLoading ? 'animate-pulse' : ''} shadow w-[200px] h-[100px] rounded-md`} />
) : (
<img src={`${getCourseThumbnailMediaDirectory(org?.org_uuid, course.courseStructure.course_uuid, course.courseStructure.thumbnail_image)}`} className='shadow w-[200px] h-[100px] rounded-md' />
)}
</div>
{isLoading ? (<div className='flex justify-center items-center'>
<input type="file" id="fileInput" style={{ display: 'none' }} onChange={handleFileChange} />
<div
className='font-bold animate-pulse antialiased items-center bg-green-200 text-gray text-sm rounded-md px-4 py-2 mt-4 flex'
>
<ArrowBigUpDash size={16} className='mr-2' />
<span>Uploading</span>
</div>
</div>) : (
<div className='flex justify-center items-center'>
<input type="file" id="fileInput" style={{ display: 'none' }} onChange={handleFileChange} />
<button
className='font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex'
onClick={() => document.getElementById('fileInput')?.click()}
>
<UploadCloud size={16} className='mr-2' />
<span>Change Thumbnail</span>
</button>
</div>
)}
</div>
</div>
</div>
)
}
export default ThumbnailUpdate

View file

@ -5,9 +5,9 @@ import { getAPIUrl } from '@services/config/config';
import { createActivity, createExternalVideoActivity, createFileActivity } from '@services/courses/activities'; import { createActivity, createExternalVideoActivity, createFileActivity } from '@services/courses/activities';
import { getOrganizationContextInfoWithoutCredentials } from '@services/organizations/orgs'; import { getOrganizationContextInfoWithoutCredentials } from '@services/organizations/orgs';
import { revalidateTags } from '@services/utils/ts/requests'; import { revalidateTags } from '@services/utils/ts/requests';
import { Layers, Sparkles } from 'lucide-react' import { Layers } from 'lucide-react'
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import React, { use, useEffect } from 'react' import React, { useEffect } from 'react'
import { mutate } from 'swr'; import { mutate } from 'swr';
type NewActivityButtonProps = { type NewActivityButtonProps = {

View file

@ -1,10 +1,8 @@
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'; import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal';
import { Activity, Hexagon, MoreHorizontal, MoreVertical, Pencil, Save, Sparkles, X } from 'lucide-react'; import { Hexagon, MoreHorizontal, MoreVertical, Pencil, Save, X } from 'lucide-react';
import React from 'react' import React from 'react'
import ActivitiyElement from './ActivityElement';
import { Draggable, Droppable } from 'react-beautiful-dnd'; import { Draggable, Droppable } from 'react-beautiful-dnd';
import ActivityElement from './ActivityElement'; import ActivityElement from './ActivityElement';
import NewActivity from '../Buttons/NewActivityButton';
import NewActivityButton from '../Buttons/NewActivityButton'; import NewActivityButton from '../Buttons/NewActivityButton';
import { deleteChapter, updateChapter } from '@services/courses/chapters'; import { deleteChapter, updateChapter } from '@services/courses/chapters';
import { revalidateTags } from '@services/utils/ts/requests'; import { revalidateTags } from '@services/utils/ts/requests';

View file

@ -1,12 +1,12 @@
'use client'; 'use client';
import { getAPIUrl } from '@services/config/config'; import { getAPIUrl } from '@services/config/config';
import { revalidateTags, swrFetcher } from '@services/utils/ts/requests'; import { revalidateTags } from '@services/utils/ts/requests';
import React, { useContext, useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import useSWR, { mutate } from 'swr'; import { mutate } from 'swr';
import ChapterElement from './DraggableElements/ChapterElement'; import ChapterElement from './DraggableElements/ChapterElement';
import PageLoading from '@components/Objects/Loaders/PageLoading'; import PageLoading from '@components/Objects/Loaders/PageLoading';
import { createChapter, updateCourseOrderStructure } from '@services/courses/chapters'; import { createChapter } from '@services/courses/chapters';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'; import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext';
import { Hexagon } from 'lucide-react'; import { Hexagon } from 'lucide-react';
@ -92,6 +92,7 @@ const EditCourseStructure = (props: EditCourseStructureProps) => {
return ( return (
<div className='flex flex-col'> <div className='flex flex-col'>
<div className="h-6"></div>
{winReady ? {winReady ?
<DragDropContext onDragEnd={updateStructure}> <DragDropContext onDragEnd={updateStructure}>
<Droppable type='chapter' droppableId='chapters'> <Droppable type='chapter' droppableId='chapters'>
@ -129,7 +130,7 @@ const EditCourseStructure = (props: EditCourseStructureProps) => {
dialogTitle="Create chapter" dialogTitle="Create chapter"
dialogDescription="Add a new chapter to the course" dialogDescription="Add a new chapter to the course"
dialogTrigger={ dialogTrigger={
<div className="mt-4 w-44 max-w-screen-2xl mx-auto bg-cyan-800 text-white rounded-xl shadow-sm px-6 items-center flex flex-row h-10"> <div className="w-44 my-16 py-5 max-w-screen-2xl mx-auto bg-cyan-800 text-white rounded-xl shadow-sm px-6 items-center flex flex-row h-10">
<div className='mx-auto flex space-x-2 items-center hover:cursor-pointer'> <div className='mx-auto flex space-x-2 items-center hover:cursor-pointer'>
<Hexagon strokeWidth={3} size={16} className="text-white text-sm " /> <Hexagon strokeWidth={3} size={16} className="text-white text-sm " />
<div className='font-bold text-sm'>Add Chapter</div></div> <div className='font-bold text-sm'>Add Chapter</div></div>

View file

@ -1,5 +1,5 @@
"use client"; "use client";
import React, { use, useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { updateOrganization, uploadOrganizationLogo } from '@services/settings/org'; import { updateOrganization, uploadOrganizationLogo } from '@services/settings/org';
import { UploadCloud } from 'lucide-react'; import { UploadCloud } from 'lucide-react';

View file

@ -1,10 +1,10 @@
import { useCourse } from '@components/Contexts/CourseContext' import { useCourse } from '@components/Contexts/CourseContext'
import { Book, ChevronRight, School, User } from 'lucide-react' import { Book, ChevronRight, School, User, Users } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import React, { use, useEffect } from 'react' import React from 'react'
type BreadCrumbsProps = { type BreadCrumbsProps = {
type: 'courses' | 'user' | 'users' | 'org' type: 'courses' | 'user' | 'users' | 'org' | 'orgusers'
last_breadcrumb?: string last_breadcrumb?: string
} }
@ -17,7 +17,9 @@ function BreadCrumbs(props: BreadCrumbsProps) {
<div className='text-gray-400 tracking-tight font-medium text-sm flex space-x-1'> <div className='text-gray-400 tracking-tight font-medium text-sm flex space-x-1'>
<div className='flex items-center space-x-1'> <div className='flex items-center space-x-1'>
{props.type == 'courses' ? <div className='flex space-x-2 items-center'> <Book className='text-gray' size={14}></Book><Link href='/dash/courses'>Courses</Link></div> : ''} {props.type == 'courses' ? <div className='flex space-x-2 items-center'> <Book className='text-gray' size={14}></Book><Link href='/dash/courses'>Courses</Link></div> : ''}
{props.type == 'user' ? <div className='flex space-x-2 items-center'> <User className='text-gray' size={14}></User><Link href='/dash/user/settings/general'>Account Settings</Link></div> : ''} {props.type == 'user' ? <div className='flex space-x-2 items-center'> <User className='text-gray' size={14}></User><Link href='/dash/user-account/settings/general'>Account Settings</Link></div> : ''}
{props.type == 'orgusers' ? <div className='flex space-x-2 items-center'> <Users className='text-gray' size={14}></Users><Link href='/dash/users/settings/users'>Organization users</Link></div> : ''}
{props.type == 'org' ? <div className='flex space-x-2 items-center'> <School className='text-gray' size={14}></School><Link href='/dash/users'>Organization Settings</Link></div> : ''} {props.type == 'org' ? <div className='flex space-x-2 items-center'> <School className='text-gray' size={14}></School><Link href='/dash/users'>Organization Settings</Link></div> : ''}
<div className='flex items-center space-x-1 first-letter:uppercase'> <div className='flex items-center space-x-1 first-letter:uppercase'>
{props.last_breadcrumb ? <ChevronRight size={17} /> : ''} {props.last_breadcrumb ? <ChevronRight size={17} /> : ''}

View file

@ -7,6 +7,8 @@ import { getUriWithOrg } from "@services/config/config";
import { useOrg } from "@components/Contexts/OrgContext"; import { useOrg } from "@components/Contexts/OrgContext";
import { getCourseThumbnailMediaDirectory } from "@services/media/media"; import { getCourseThumbnailMediaDirectory } from "@services/media/media";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import EmptyThumbnailImage from '../../../public/empty_thumbnail.png';
export function CourseOverviewTop({ params }: { params: CourseOverviewParams }) { export function CourseOverviewTop({ params }: { params: CourseOverviewParams }) {
const course = useCourse() as any; const course = useCourse() as any;
@ -21,7 +23,10 @@ export function CourseOverviewTop({ params }: { params: CourseOverviewParams })
<div className='flex'> <div className='flex'>
<div className='flex py-5 grow items-center'> <div className='flex py-5 grow items-center'>
<Link href={getUriWithOrg(org?.slug, "") + `/course/${params.courseuuid}`}> <Link href={getUriWithOrg(org?.slug, "") + `/course/${params.courseuuid}`}>
{course?.courseStructure?.thumbnail_image ?
<img className="w-[100px] h-[57px] rounded-md drop-shadow-md" src={`${getCourseThumbnailMediaDirectory(org?.org_uuid, "course_" + params.courseuuid, course.courseStructure.thumbnail_image)}`} alt="" /> <img className="w-[100px] h-[57px] rounded-md drop-shadow-md" src={`${getCourseThumbnailMediaDirectory(org?.org_uuid, "course_" + params.courseuuid, course.courseStructure.thumbnail_image)}`} alt="" />
:
<Image width={100} className="h-[57px] rounded-md drop-shadow-md" src={EmptyThumbnailImage} alt="" />}
</Link> </Link>
<div className="flex flex-col course_metadata justify-center pl-5"> <div className="flex flex-col course_metadata justify-center pl-5">
<div className='text-gray-400 font-semibold text-sm'>Course</div> <div className='text-gray-400 font-semibold text-sm'>Course</div>

View file

@ -4,12 +4,13 @@ import { useSession } from '@components/Contexts/SessionContext';
import ToolTip from '@components/StyledElements/Tooltip/Tooltip' import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
import LearnHouseDashboardLogo from '@public/dashLogo.png'; import LearnHouseDashboardLogo from '@public/dashLogo.png';
import { logout } from '@services/auth/auth'; import { logout } from '@services/auth/auth';
import Avvvatars from 'avvvatars-react'; import { BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react'
import { ArrowLeft, Book, BookCopy, Home, LogOut, School, Settings } from 'lucide-react'
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import React, { use, useEffect } from 'react' import React, { useEffect } from 'react'
import UserAvatar from '../../Objects/UserAvatar';
import AdminAuthorization from '@components/Security/AdminAuthorization';
function LeftMenu() { function LeftMenu() {
const org = useOrg() as any; const org = useOrg() as any;
@ -42,8 +43,8 @@ function LeftMenu() {
return ( return (
<div <div
style={{ background: "linear-gradient(0deg, rgba(0, 0, 0, 0.20) 0%, rgba(0, 0, 0, 0.20) 100%), radial-gradient(271.56% 105.16% at 50% -5.16%, rgba(255, 255, 255, 0.18) 0%, rgba(0, 0, 0, 0.00) 100%), #2E2D2D" }} style={{ background: "linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%), radial-gradient(271.56% 105.16% at 50% -5.16%, rgba(255, 255, 255, 0.18) 0%, rgba(0, 0, 0, 0) 100%), rgb(20 19 19)" }}
className='flex flex-col w-28 bg-black h-screen text-white shadow-xl'> className='flex flex-col w-[90px] bg-black h-screen text-white shadow-xl'>
<div className='flex flex-col h-full'> <div className='flex flex-col h-full'>
<div className='flex h-20 mt-6'> <div className='flex h-20 mt-6'>
<Link className='flex flex-col items-center mx-auto space-y-3' href={"/"}> <Link className='flex flex-col items-center mx-auto space-y-3' href={"/"}>
@ -59,27 +60,32 @@ function LeftMenu() {
{/* <ToolTip content={"Back to " + org?.name + "'s Home"} slateBlack sideOffset={8} side='right' > {/* <ToolTip content={"Back to " + org?.name + "'s Home"} slateBlack sideOffset={8} side='right' >
<Link className='bg-white text-black hover:text-white rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/`} ><ArrowLeft className='hover:text-white' size={18} /></Link> <Link className='bg-white text-black hover:text-white rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/`} ><ArrowLeft className='hover:text-white' size={18} /></Link>
</ToolTip> */} </ToolTip> */}
<ToolTip content={"Home"} slateBlack sideOffset={8} side='right' > <AdminAuthorization authorizationMode="component">
<Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash`} ><Home size={18} /></Link> <ToolTip content={"Home"} slateBlack sideOffset={8} side='right' >
</ToolTip> <Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash`} ><Home size={18} /></Link>
<ToolTip content={"Courses"} slateBlack sideOffset={8} side='right' > </ToolTip>
<Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash/courses`} ><BookCopy size={18} /></Link> <ToolTip content={"Courses"} slateBlack sideOffset={8} side='right' >
</ToolTip> <Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash/courses`} ><BookCopy size={18} /></Link>
<ToolTip content={"Organization"} slateBlack sideOffset={8} side='right' > </ToolTip>
<Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash/org/settings/general`} ><School size={18} /></Link> <ToolTip content={"Users"} slateBlack sideOffset={8} side='right' >
</ToolTip> <Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash/users/settings/users`} ><Users size={18} /></Link>
</ToolTip>
<ToolTip content={"Organization"} slateBlack sideOffset={8} side='right' >
<Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash/org/settings/general`} ><School size={18} /></Link>
</ToolTip>
</AdminAuthorization>
</div> </div>
<div className='flex flex-col mx-auto pb-7 space-y-2'> <div className='flex flex-col mx-auto pb-7 space-y-2'>
<div className="flex items-center flex-col space-y-2"> <div className="flex items-center flex-col space-y-2">
<ToolTip content={session.user.username} slateBlack sideOffset={8} side='right' > <ToolTip content={'@' + session.user.username} slateBlack sideOffset={8} side='right' >
<div className="mx-auto shadow-lg"> <div className='mx-auto'>
<Avvvatars radius={3} border borderColor='white' borderSize={3} size={35} value={session.user.user_uuid} style="shape" /> <UserAvatar border='border-4' width={35} />
</div> </div>
</ToolTip> </ToolTip>
<div className='flex items-center flex-col space-y-1'> <div className='flex items-center flex-col space-y-1'>
<ToolTip content={session.user.username + "'s Settings"} slateBlack sideOffset={8} side='right' > <ToolTip content={session.user.username + "'s Settings"} slateBlack sideOffset={8} side='right' >
<Link href={'/dash/user/settings/general'} className='py-3'> <Link href={'/dash/user-account/settings/general'} className='py-3'>
<Settings className='mx-auto text-neutral-400 cursor-pointer' size={18} /> <Settings className='mx-auto text-neutral-400 cursor-pointer' size={18} />
</Link> </Link>
</ToolTip> </ToolTip>

View file

@ -1,100 +0,0 @@
import { updateProfile } from '@services/settings/profile';
import React, { useEffect } from 'react'
import { Formik, Form, Field, ErrorMessage } from 'formik';
import { useSession } from '@components/Contexts/SessionContext';
function UserEditGeneral() {
const session = useSession() as any;
useEffect(() => {
}
, [session, session.user])
return (
<div className='ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-6 py-5'>
{session.user && (
<Formik
enableReinitialize
initialValues={{
username: session.user.username,
first_name: session.user.first_name,
last_name: session.user.last_name,
email: session.user.email,
bio: session.user.bio,
}}
onSubmit={(values, { setSubmitting }) => {
setTimeout(() => {
setSubmitting(false);
updateProfile(values,session.user.id)
}, 400);
}}
>
{({ isSubmitting }) => (
<Form className="max-w-md">
<label className="block mb-2 font-bold" htmlFor="email">
Email
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="email"
name="email"
/>
<label className="block mb-2 font-bold" htmlFor="username">
Username
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="username"
name="username"
/>
<label className="block mb-2 font-bold" htmlFor="first_name">
First Name
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="first_name"
name="first_name"
/>
<label className="block mb-2 font-bold" htmlFor="last_name">
Last Name
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="last_name"
name="last_name"
/>
<label className="block mb-2 font-bold" htmlFor="bio">
Bio
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="bio"
name="bio"
/>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-black focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Submit
</button>
</Form>
)}
</Formik>
)}
</div>
)
}
export default UserEditGeneral

View file

@ -0,0 +1,181 @@
import { updateProfile } from '@services/settings/profile';
import React, { useEffect } from 'react'
import { Formik, Form, Field } from 'formik';
import { useSession } from '@components/Contexts/SessionContext';
import { ArrowBigUpDash, Check, FileWarning, Info, UploadCloud } from 'lucide-react';
import UserAvatar from '@components/Objects/UserAvatar';
import { updateUserAvatar } from '@services/users/users';
function UserEditGeneral() {
const session = useSession() as any;
const [localAvatar, setLocalAvatar] = React.useState(null) as any;
const [isLoading, setIsLoading] = React.useState(false) as any;
const [error, setError] = React.useState() as any;
const [success, setSuccess] = React.useState('') as any;
const handleFileChange = async (event: any) => {
const file = event.target.files[0];
setLocalAvatar(file);
setIsLoading(true);
const res = await updateUserAvatar(session.user.user_uuid, file)
// wait for 1 second to show loading animation
await new Promise(r => setTimeout(r, 1500));
if (res.success === false) {
setError(res.HTTPmessage);
} else {
setIsLoading(false);
setError('');
setSuccess('Avatar Updated');
}
};
useEffect(() => {
}
, [session, session.user])
return (
<div className='ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-6 py-5'>
{session.user && (
<Formik
enableReinitialize
initialValues={{
username: session.user.username,
first_name: session.user.first_name,
last_name: session.user.last_name,
email: session.user.email,
bio: session.user.bio,
}}
onSubmit={(values, { setSubmitting }) => {
setTimeout(() => {
setSubmitting(false);
updateProfile(values, session.user.id)
}, 400);
}}
>
{({ isSubmitting }) => (
<div className='flex space-x-8'>
<Form className="max-w-md">
<label className="block mb-2 font-bold" htmlFor="email">
Email
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="email"
name="email"
/>
<label className="block mb-2 font-bold" htmlFor="username">
Username
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="username"
name="username"
/>
<label className="block mb-2 font-bold" htmlFor="first_name">
First Name
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="first_name"
name="first_name"
/>
<label className="block mb-2 font-bold" htmlFor="last_name">
Last Name
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="last_name"
name="last_name"
/>
<label className="block mb-2 font-bold" htmlFor="bio">
Bio
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="bio"
name="bio"
/>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-black focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Submit
</button>
</Form>
<div className='flex flex-col grow justify-center align-middle space-y-3'>
<label className="flex mx-auto mb-2 font-bold " >
Avatar
</label>
{error && (
<div className="flex justify-center mx-auto bg-red-200 rounded-md text-red-950 space-x-1 px-4 items-center p-2 transition-all shadow-sm">
<FileWarning size={16} className='mr-2' />
<div className="text-sm font-semibold first-letter:uppercase">{error}</div>
</div>
)}
{success && (
<div className="flex justify-center mx-auto bg-green-200 rounded-md text-green-950 space-x-1 px-4 items-center p-2 transition-all shadow-sm">
<Check size={16} className='mr-2' />
<div className="text-sm font-semibold first-letter:uppercase">{success}</div>
</div>
)}
<div className="flex flex-col space-y-3">
<div className='w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow mx-20'>
<div className='flex flex-col justify-center items-center mt-10'>
{localAvatar ? (
<UserAvatar border='border-8' width={100} avatar_url={URL.createObjectURL(localAvatar)} />
) : (
<UserAvatar border='border-8' width={100} />
)}
</div>
{isLoading ? (<div className='flex justify-center items-center'>
<input type="file" id="fileInput" style={{ display: 'none' }} onChange={handleFileChange} />
<div
className='font-bold animate-pulse antialiased items-center bg-green-200 text-gray text-sm rounded-md px-4 py-2 mt-4 flex'
>
<ArrowBigUpDash size={16} className='mr-2' />
<span>Uploading</span>
</div>
</div>) : (
<div className='flex justify-center items-center'>
<input type="file" id="fileInput" style={{ display: 'none' }} onChange={handleFileChange} />
<button
className='font-bold antialiased items-center text-gray text-sm rounded-md px-4 py-2 mt-4 flex'
onClick={() => document.getElementById('fileInput')?.click()}
>
<UploadCloud size={16} className='mr-2' />
<span>Change Thumbnail</span>
</button>
</div> )}
</div>
<div className='flex text-xs space-x-2 items-center text-gray-500 justify-center'>
<Info size={13} /><p>Recommended size 100x100</p>
</div>
</div>
</div>
</div>
)}
</Formik>
)}
</div>
)
}
export default UserEditGeneral

View file

@ -1,6 +1,6 @@
import { useSession } from '@components/Contexts/SessionContext'; import { useSession } from '@components/Contexts/SessionContext';
import { updatePassword } from '@services/settings/password'; import { updatePassword } from '@services/settings/password';
import { Formik, Form, Field, ErrorMessage } from 'formik'; import { Formik, Form, Field } from 'formik';
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
function UserEditPassword() { function UserEditPassword() {

View file

@ -0,0 +1,175 @@
import { useOrg } from '@components/Contexts/OrgContext'
import PageLoading from '@components/Objects/Loaders/PageLoading';
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal';
import { getAPIUrl, getUriWithOrg } from '@services/config/config';
import { swrFetcher } from '@services/utils/ts/requests';
import { Globe, Shield, X } from 'lucide-react'
import Link from 'next/link';
import React, { useEffect } from 'react'
import useSWR, { mutate } from 'swr';
import dayjs from 'dayjs';
import { changeSignupMechanism, createInviteCode, deleteInviteCode } from '@services/organizations/invites';
import Toast from '@components/StyledElements/Toast/Toast';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
function OrgAccess() {
const org = useOrg() as any;
const { data: invites } = useSWR(org ? `${getAPIUrl()}orgs/${org?.id}/invites` : null, swrFetcher);
const [isLoading, setIsLoading] = React.useState(false)
const [joinMethod, setJoinMethod] = React.useState('closed')
const router = useRouter()
async function getOrgJoinMethod() {
if (org) {
if (org.config.config.GeneralConfig.users.signup_mechanism == 'open') {
setJoinMethod('open')
}
else {
setJoinMethod('inviteOnly')
}
}
}
async function createInvite() {
let res = await createInviteCode(org.id)
if (res.status == 200) {
mutate(`${getAPIUrl()}orgs/${org.id}/invites`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
async function deleteInvite(invite: any) {
let res = await deleteInviteCode(org.id, invite.invite_code_uuid)
if (res.status == 200) {
mutate(`${getAPIUrl()}orgs/${org.id}/invites`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
async function changeJoinMethod(method: 'open' | 'inviteOnly') {
let res = await changeSignupMechanism(org.id, method)
if (res.status == 200) {
router.refresh()
mutate(`${getAPIUrl()}orgs/slug/${org?.slug}`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
useEffect(() => {
if (invites && org) {
getOrgJoinMethod()
setIsLoading(false)
}
}
, [org, invites])
return (
<>
<Toast></Toast>
{!isLoading ? (<>
<div className="h-6"></div>
<div className='ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4 anit '>
<div className='flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3 '>
<h1 className='font-bold text-xl text-gray-800'>Join method</h1>
<h2 className='text-gray-500 text-md'> Choose how users can join your organization </h2>
</div>
<div className='flex space-x-2 mx-auto'>
<ConfirmationModal
confirmationButtonText='Change to open '
confirmationMessage='Are you sure you want to change the signup mechanism to open ? This will allow users to join your organization freely.'
dialogTitle={'Change to open ?'}
dialogTrigger={
<div className='w-full h-[160px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 ease-linear transition-all'>
{joinMethod == 'open' ? <div className='bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg'>Active</div> : null}
<div className='flex flex-col space-y-1 justify-center items-center h-full'>
<Globe className='text-slate-400' size={40}></Globe>
<div className='text-2xl text-slate-700 font-bold'>Open</div>
<div className='text-gray-400 text-center'>Users can join freely from the signup page</div>
</div>
</div>}
functionToExecute={() => { changeJoinMethod('open') }}
status='info'
></ConfirmationModal>
<ConfirmationModal
confirmationButtonText='Change to closed '
confirmationMessage='Are you sure you want to change the signup mechanism to closed ? This will allow users to join your organization only by invitation.'
dialogTitle={'Change to closed ?'}
dialogTrigger={
<div className='w-full h-[160px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 ease-linear transition-all'>
{joinMethod == 'inviteOnly' ? <div className='bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg'>Active</div> : null}
<div className='flex flex-col space-y-1 justify-center items-center h-full'>
<Shield className='text-slate-400' size={40}></Shield>
<div className='text-2xl text-slate-700 font-bold'>Closed</div>
<div className='text-gray-400 text-center'>Users can join only by invitation</div>
</div>
</div>}
functionToExecute={() => { changeJoinMethod('inviteOnly') }}
status='info'
></ConfirmationModal>
</div>
<div className={joinMethod == 'open' ? 'opacity-20 pointer-events-none' : 'pointer-events-auto'}>
<div className='flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mt-3 mb-3 '>
<h1 className='font-bold text-xl text-gray-800'>Invite codes</h1>
<h2 className='text-gray-500 text-md'>Invite codes can be copied and used to join your organization </h2>
</div>
<table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden">
<thead className='bg-gray-100 text-gray-500 rounded-xl uppercase'>
<tr className='font-bolder text-sm'>
<th className='py-3 px-4'>Code</th>
<th className='py-3 px-4'>Signup link</th>
<th className='py-3 px-4'>Expiration date</th>
<th className='py-3 px-4'>Actions</th>
</tr>
</thead>
<>
<tbody className='mt-5 bg-white rounded-md' >
{invites?.map((invite: any) => (
<tr key={invite.invite_code_uuid} className='border-b border-gray-100 text-sm'>
<td className='py-3 px-4'>{invite.invite_code}</td>
<td className='py-3 px-4 '>
<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}`)}>
{getUriWithOrg(org?.slug, `/signup?inviteCode=${invite.invite_code}`)}
</Link>
</td>
<td className='py-3 px-4'>{dayjs(invite.expiration_date).add(1, 'year').format('DD/MM/YYYY')} </td>
<td className='py-3 px-4'>
<ConfirmationModal
confirmationButtonText='Delete Code'
confirmationMessage='Are you sure you want remove this invite code ?'
dialogTitle={'Delete code ?'}
dialogTrigger={
<button className='mr-2 flex space-x-2 hover:cursor-pointer p-1 px-3 bg-rose-700 rounded-md font-bold items-center text-sm text-rose-100'>
<X className='w-4 h-4' />
<span> Delete code</span>
</button>}
functionToExecute={() => { deleteInvite(invite) }}
status='warning'
></ConfirmationModal>
</td>
</tr>
))}
</tbody>
</>
</table>
<button onClick={() => createInvite()} className='mt-3 mr-2 flex space-x-2 hover:cursor-pointer p-1 px-3 bg-green-700 rounded-md font-bold items-center text-sm text-green-100'>
<Shield className='w-4 h-4' />
<span> Create invite code</span>
</button>
</div>
</div></>) : <PageLoading />}
</>
)
}
export default OrgAccess

View file

@ -0,0 +1,120 @@
import { useOrg } from '@components/Contexts/OrgContext';
import PageLoading from '@components/Objects/Loaders/PageLoading';
import RolesUpdate from '@components/Objects/Modals/Dash/OrgUsers/RolesUpdate';
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal';
import Modal from '@components/StyledElements/Modal/Modal';
import Toast from '@components/StyledElements/Toast/Toast';
import { getAPIUrl } from '@services/config/config';
import { removeUserFromOrg } from '@services/organizations/orgs';
import { swrFetcher } from '@services/utils/ts/requests';
import { KeyRound, LogOut } from 'lucide-react';
import React, { useEffect } from 'react'
import toast from 'react-hot-toast';
import useSWR, { mutate } from 'swr';
function OrgUsers() {
const org = useOrg() as any;
const { data: orgUsers } = useSWR(org ? `${getAPIUrl()}orgs/${org?.id}/users` : null, swrFetcher);
const [rolesModal, setRolesModal] = React.useState(false);
const [selectedUser, setSelectedUser] = React.useState(null) as any;
const [isLoading, setIsLoading] = React.useState(true);
const handleRolesModal = (user_uuid: any) => {
setSelectedUser(user_uuid);
setRolesModal(!rolesModal);
}
const handleRemoveUser = async (user_id: any) => {
const res = await removeUserFromOrg(org.id, user_id);
if (res.status === 200) {
await mutate(`${getAPIUrl()}orgs/${org.id}/users`);
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
useEffect(() => {
if (orgUsers) {
setIsLoading(false)
console.log(orgUsers)
}
}, [org, orgUsers])
return (
<div>
{isLoading ? <div><PageLoading /></div> :
<>
<Toast></Toast>
<div className="h-6"></div>
<div className='ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4 '>
<div className='flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3 '>
<h1 className='font-bold text-xl text-gray-800'>Active users</h1>
<h2 className='text-gray-500 text-md'> Manage your organization users, assign roles and permissions </h2>
</div>
<table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden">
<thead className='bg-gray-100 text-gray-500 rounded-xl uppercase'>
<tr className='font-bolder text-sm'>
<th className='py-3 px-4'>User</th>
<th className='py-3 px-4'>Role</th>
<th className='py-3 px-4'>Actions</th>
</tr>
</thead>
<>
<tbody className='mt-5 bg-white rounded-md' >
{orgUsers?.map((user: any) => (
<tr key={user.user.id} className='border-b border-gray-200 border-dashed'>
<td className='py-3 px-4 flex space-x-2 items-center'>
<span>{user.user.first_name + ' ' + user.user.last_name}</span>
<span className='text-xs bg-neutral-100 p-1 px-2 rounded-full text-neutral-400 font-semibold'>@{user.user.username}</span>
</td>
<td className='py-3 px-4'>{user.role.name}</td>
<td className='py-3 px-4 flex space-x-2 items-end'>
<Modal
isDialogOpen={rolesModal && selectedUser === user.user.user_uuid}
onOpenChange={() => handleRolesModal(user.user.user_uuid)}
minHeight="no-min"
dialogContent={
<RolesUpdate
alreadyAssignedRole={user.role.role_uuid}
setRolesModal={setRolesModal}
user={user} />
}
dialogTitle="Update Role"
dialogDescription={"Update @" + user.user.username + "'s role"}
dialogTrigger={
<button className='flex space-x-2 hover:cursor-pointer p-1 px-3 bg-yellow-700 rounded-md font-bold items-center text-sm text-yellow-100'>
<KeyRound className='w-4 h-4' />
<span> Edit Role</span>
</button>}
/>
<ConfirmationModal
confirmationButtonText='Remove User'
confirmationMessage='Are you sure you want remove this user from the organization?'
dialogTitle={'Delete ' + user.user.username + ' ?'}
dialogTrigger={
<button className='mr-2 flex space-x-2 hover:cursor-pointer p-1 px-3 bg-rose-700 rounded-md font-bold items-center text-sm text-rose-100'>
<LogOut className='w-4 h-4' />
<span> Remove from organization</span>
</button>}
functionToExecute={() => { handleRemoveUser(user.user.id) }}
status='warning'
></ConfirmationModal>
</td>
</tr>
))}
</tbody>
</>
</table>
</div>
</>
}
</div>
)
}
export default OrgUsers

View file

@ -1,18 +1,15 @@
import { useSession } from '@components/Contexts/SessionContext' import { useSession } from '@components/Contexts/SessionContext'
import { sendActivityAIChatMessage, startActivityAIChatSession } from '@services/ai/ai'; import { sendActivityAIChatMessage, startActivityAIChatSession } from '@services/ai/ai';
import { AlertTriangle, BadgeInfo, NotebookTabs } from 'lucide-react'; import { AlertTriangle, BadgeInfo, NotebookTabs } from 'lucide-react';
import Avvvatars from 'avvvatars-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { FlaskConical, Keyboard, MessageCircle, MessageSquareIcon, Sparkle, Sparkles, X } from 'lucide-react' import { FlaskConical, MessageCircle, X } from 'lucide-react'
import Image from 'next/image'; import Image from 'next/image';
import { send } from 'process';
import learnhouseAI_icon from "public/learnhouse_ai_simple.png"; import learnhouseAI_icon from "public/learnhouse_ai_simple.png";
import learnhouseAI_logo_black from "public/learnhouse_ai_black_logo.png"; import learnhouseAI_logo_black from "public/learnhouse_ai_black_logo.png";
import React, { use, useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import { AIChatBotStateTypes, useAIChatBot, useAIChatBotDispatch } from '@components/Contexts/AI/AIChatBotContext'; import { AIChatBotStateTypes, useAIChatBot, useAIChatBotDispatch } from '@components/Contexts/AI/AIChatBotContext';
import FeedbackModal from '@components/Objects/Modals/Feedback/Feedback';
import Modal from '@components/StyledElements/Modal/Modal';
import useGetAIFeatures from '../../../AI/Hooks/useGetAIFeatures'; import useGetAIFeatures from '../../../AI/Hooks/useGetAIFeatures';
import UserAvatar from '@components/Objects/UserAvatar';
type AIActivityAskProps = { type AIActivityAskProps = {
@ -172,7 +169,7 @@ function ActivityChatMessageBox(props: ActivityChatMessageBoxProps) {
</div> </div>
<div className='bg-white/5 text-white/40 py-0.5 px-3 flex space-x-1 rounded-full items-center'> <div className='bg-white/5 text-white/40 py-0.5 px-3 flex space-x-1 rounded-full items-center'>
<FlaskConical size={14} /> <FlaskConical size={14} />
<span className='text-xs font-semibold '>Experimental</span> <span className='text-xs font-semibold antialiased '>Experimental</span>
</div> </div>
</div> </div>
@ -204,7 +201,7 @@ function ActivityChatMessageBox(props: ActivityChatMessageBoxProps) {
} }
<div className='flex space-x-2 items-center'> <div className='flex space-x-2 items-center'>
<div className=''> <div className=''>
<Avvvatars radius={3} border borderColor='white' borderSize={3} size={35} value={session.user.user_uuid} style="shape" /> <UserAvatar rounded='rounded-lg' border='border-2' width={35} />
</div> </div>
<div className='w-full'> <div className='w-full'>
<input onKeyDown={handleKeyDown} onChange={handleChange} disabled={aiChatBotState.isWaitingForResponse} value={aiChatBotState.chatInputValue} placeholder='Ask AI About this Lecture' type="text" className={inputClass} name="" id="" /> <input onKeyDown={handleKeyDown} onChange={handleChange} disabled={aiChatBotState.isWaitingForResponse} value={aiChatBotState.chatInputValue} placeholder='Ask AI About this Lecture' type="text" className={inputClass} name="" id="" />
@ -235,7 +232,11 @@ function AIMessage(props: AIMessageProps) {
return ( return (
<div className='flex space-x-2 w-full antialiased font-medium'> <div className='flex space-x-2 w-full antialiased font-medium'>
<div className=''> <div className=''>
<Avvvatars radius={3} border borderColor='white' borderSize={3} size={35} value={props.message.type == 'ai' ? 'ai' : session.user.user_uuid} style="shape" /> {props.message.sender == 'ai' ? (
<UserAvatar rounded='rounded-lg' border='border-2' predefined_avatar='ai' width={35} />
) : (
<UserAvatar rounded='rounded-lg' border='border-2' width={35} />
)}
</div> </div>
<div className='w-full'> <div className='w-full'>
<p className='w-full rounded-lg outline-none px-2 py-1 text-white text-md placeholder:text-white/30' id=""> <p className='w-full rounded-lg outline-none px-2 py-1 text-white text-md placeholder:text-white/30' id="">
@ -277,7 +278,8 @@ const AIMessagePlaceHolder = (props: { activity_uuid: string, sendMessage: any }
<Image width={100} className='mx-auto' src={learnhouseAI_logo_black} alt="" /> <Image width={100} className='mx-auto' src={learnhouseAI_logo_black} alt="" />
<p className='pt-3 text-2xl font-semibold text-white/70 flex justify-center space-x-2 items-center'> <p className='pt-3 text-2xl font-semibold text-white/70 flex justify-center space-x-2 items-center'>
<span className='items-center'>Hello</span> <span className='items-center'>Hello</span>
<span className='capitalize flex space-x-2 items-center'> <Avvvatars radius={3} border borderColor='white' borderSize={3} size={25} value={session.user.user_uuid} style="shape" /> <span className='capitalize flex space-x-2 items-center'>
<UserAvatar rounded='rounded-lg' border='border-2' width={35} />
<span>{session.user.username},</span> <span>{session.user.username},</span>
</span> </span>
<span>how can we help today ?</span> <span>how can we help today ?</span>

View file

@ -1,5 +1,4 @@
import { useOrg } from "@components/Contexts/OrgContext"; import { useOrg } from "@components/Contexts/OrgContext";
import { getBackendUrl } from "@services/config/config";
import { getActivityMediaDirectory } from "@services/media/media"; import { getActivityMediaDirectory } from "@services/media/media";
import React from "react"; import React from "react";

View file

@ -1,4 +1,4 @@
import { useEditor, EditorContent, BubbleMenu, EditorProvider } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import styled from "styled-components" import styled from "styled-components"
import Youtube from "@tiptap/extension-youtube"; import Youtube from "@tiptap/extension-youtube";

View file

@ -1,6 +1,4 @@
import { getBackendUrl } from "@services/config/config";
import React from "react"; import React from "react";
import styled from "styled-components";
import YouTube from 'react-youtube'; import YouTube from 'react-youtube';
import { getActivityMediaDirectory } from "@services/media/media"; import { getActivityMediaDirectory } from "@services/media/media";
import { useOrg } from "@components/Contexts/OrgContext"; import { useOrg } from "@components/Contexts/OrgContext";

View file

@ -2,9 +2,8 @@ import React from 'react'
import learnhouseAI_icon from "public/learnhouse_ai_simple.png"; import learnhouseAI_icon from "public/learnhouse_ai_simple.png";
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { AlertTriangle, BetweenHorizontalStart, FastForward, Feather, FileStack, HelpCircle, Languages, MessageCircle, MoreVertical, Pen, X } from 'lucide-react'; import { AlertTriangle, BetweenHorizontalStart, FastForward, Feather, FileStack, HelpCircle, Languages, MoreVertical, X } from 'lucide-react';
import { Editor } from '@tiptap/react'; import { Editor } from '@tiptap/react';
import { AIChatBotStateTypes, useAIChatBot, useAIChatBotDispatch } from '@components/Contexts/AI/AIChatBotContext';
import { AIEditorStateTypes, useAIEditor, useAIEditorDispatch } from '@components/Contexts/AI/AIEditorContext'; import { AIEditorStateTypes, useAIEditor, useAIEditorDispatch } from '@components/Contexts/AI/AIEditorContext';
import { sendActivityAIChatMessage, startActivityAIChatSession } from '@services/ai/ai'; import { sendActivityAIChatMessage, startActivityAIChatSession } from '@services/ai/ai';
import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures'; import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures';
@ -57,7 +56,7 @@ function AIEditorToolkit(props: AIEditorToolkitProps) {
<div className='pr-1'> <div className='pr-1'>
<div className='flex w-full space-x-2 font-bold text-white/80 items-center'> <div className='flex w-full space-x-2 font-bold text-white/80 items-center'>
<Image className='outline outline-1 outline-neutral-200/20 rounded-lg' width={24} src={learnhouseAI_icon} alt="" /> <Image className='outline outline-1 outline-neutral-200/20 rounded-lg' width={24} src={learnhouseAI_icon} alt="" />
<div >AI Editor</div> <div className='flex items-center'>AI Editor <span className='text-[10px] px-2 py-1 rounded-3xl ml-3 bg-white/10 uppercase'>PRE-ALPHA</span></div>
<MoreVertical className='text-white/50' size={12} /> <MoreVertical className='text-white/50' size={12} />
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import React from "react"; import React from "react";
import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import learnhouseIcon from "public/learnhouse_icon.png"; import learnhouseIcon from "public/learnhouse_icon.png";
import { ToolbarButtons } from "./Toolbar/ToolbarButtons"; import { ToolbarButtons } from "./Toolbar/ToolbarButtons";
@ -8,7 +8,6 @@ import { motion } from "framer-motion";
import Image from "next/image"; import Image from "next/image";
import styled from "styled-components"; import styled from "styled-components";
import { DividerVerticalIcon, SlashIcon } from "@radix-ui/react-icons"; import { DividerVerticalIcon, SlashIcon } from "@radix-ui/react-icons";
import Avvvatars from "avvvatars-react";
import learnhouseAI_icon from "public/learnhouse_ai_simple.png"; import learnhouseAI_icon from "public/learnhouse_ai_simple.png";
import { AIEditorStateTypes, useAIEditor, useAIEditorDispatch } from "@components/Contexts/AI/AIEditorContext"; import { AIEditorStateTypes, useAIEditor, useAIEditorDispatch } from "@components/Contexts/AI/AIEditorContext";
@ -42,6 +41,7 @@ import { CourseProvider } from "@components/Contexts/CourseContext";
import { useSession } from "@components/Contexts/SessionContext"; import { useSession } from "@components/Contexts/SessionContext";
import AIEditorToolkit from "./AI/AIEditorToolkit"; import AIEditorToolkit from "./AI/AIEditorToolkit";
import useGetAIFeatures from "@components/AI/Hooks/useGetAIFeatures"; import useGetAIFeatures from "@components/AI/Hooks/useGetAIFeatures";
import UserAvatar from "../UserAvatar";
interface Editor { interface Editor {
@ -163,7 +163,7 @@ function Editor(props: Editor) {
<Link href="/"> <Link href="/">
<EditorInfoLearnHouseLogo width={25} height={25} src={learnhouseIcon} alt="" /> <EditorInfoLearnHouseLogo width={25} height={25} src={learnhouseIcon} alt="" />
</Link> </Link>
<Link target="_blank" href={`/course/${course_uuid}/edit`}> <Link target="_blank" href={`/course/${course_uuid}`}>
<EditorInfoThumbnail src={`${getCourseThumbnailMediaDirectory(props.org?.org_uuid, props.course.course_uuid, props.course.thumbnail_image)}`} alt=""></EditorInfoThumbnail> <EditorInfoThumbnail src={`${getCourseThumbnailMediaDirectory(props.org?.org_uuid, props.course.course_uuid, props.course.thumbnail_image)}`} alt=""></EditorInfoThumbnail>
</Link> </Link>
<EditorInfoDocName> <EditorInfoDocName>
@ -207,7 +207,7 @@ function Editor(props: Editor) {
<EditorUserProfileWrapper> <EditorUserProfileWrapper>
{!session.isAuthenticated && <span>Loading</span>} {!session.isAuthenticated && <span>Loading</span>}
{session.isAuthenticated && <Avvvatars value={session.user.user_uuid} style="shape" />} {session.isAuthenticated && <UserAvatar width={40} border="border-4" rounded="rounded-full"/>}
</EditorUserProfileWrapper> </EditorUserProfileWrapper>
</EditorUsersSection> </EditorUsersSection>

Some files were not shown because too many files have changed in this diff Show more