Merge branch 'main' of https://github.com/classroomio/classroomio
This commit is contained in:
@@ -411,3 +411,68 @@ When moving from `apps/dashboard/src/lib/components/` to `apps/dashboard/src/lib
|
||||
3. **Update exports** in `features/ui/index.ts`
|
||||
4. **Update imports**: `import { Component } from '$features/ui';`
|
||||
5. **Delete old files** and verify no remaining references
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
The full setup/run flow lives in `README.md` and `DEV_SETUP_NOTES.md`. The notes below cover only the non-obvious, cloud-VM-specific caveats. The update script already runs `pnpm install` on startup.
|
||||
|
||||
### Node version (important)
|
||||
The sandbox injects `/exec-daemon/node` (Node 22) at the front of `PATH`, so a bare `node`/`pnpm` runs under Node 22 even though the repo requires Node `^20.19.3` (`.nvmrc`). Node 20.19.3 is installed via nvm and set as the nvm default. Before running any `pnpm`/dev command, prepend the repo Node to `PATH`:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.nvm/versions/node/v20.19.3/bin:$PATH"
|
||||
```
|
||||
|
||||
`pnpm install` itself succeeds under either version (there is no `engine-strict`), but run the dev servers and DB scripts on Node 20.
|
||||
|
||||
### Services & how to run them
|
||||
Docker is installed and provides Postgres 16 + Redis 7 (the app does not run them natively). After a fresh VM boot the Docker daemon is usually not running — start it (it needs the snapshot's `/etc/docker/daemon.json`, which already sets `fuse-overlayfs` + disables the containerd snapshotter, plus `iptables-legacy`):
|
||||
|
||||
```bash
|
||||
sudo dockerd > /tmp/dockerd.log 2>&1 &
|
||||
sudo chmod 666 /var/run/docker.sock # or prefix docker commands with sudo
|
||||
docker compose -f docker/docker-compose.yaml up -d postgres redis
|
||||
```
|
||||
|
||||
The per-app `.env` files (`apps/api/.env`, `apps/dashboard/.env`, `apps/jobs/.env`, `packages/db/.env`) are gitignored but persist in the VM snapshot with a matching `PRIVATE_SERVER_KEY`. If any are missing, recreate them per `README.md` (generate secrets with `openssl rand -hex 32`; the two `PRIVATE_SERVER_KEY` values must match).
|
||||
|
||||
The Postgres docker volume persists in the snapshot already seeded with demo data (login `[email protected]` / `123456`). Re-seed only if needed: `pnpm --filter @cio/db db:setup:seed`.
|
||||
|
||||
Run the app (two long-lived processes; use tmux):
|
||||
- `pnpm api:dev` → API on `http://localhost:3002` + 5 BullMQ workers.
|
||||
- `pnpm dashboard:dev` → dashboard on `http://localhost:5173` + the `@cio/ui` Tailwind CSS watcher.
|
||||
|
||||
Do not use `pnpm dev` (it trips turbo's concurrency cap). Build shared package `dist/` once with `pnpm build` if you see `Failed to resolve entry for package "@cio/*"`.
|
||||
|
||||
### Known caveats
|
||||
- **Vite SSR circular dependency (layerchart):** authenticated/chart pages occasionally render "Something unexpected occurred" on a cold load. Reload the page (or restart `dashboard:dev`) and it renders — it is intermittent, not a setup failure.
|
||||
- **MinIO is optional and not started by default.** Without it, image/media thumbnails show "Failed to load"; that is expected. Start it with `docker compose -f docker-compose.yaml --profile minio up -d minio minio-init` and add the `OBJECT_STORAGE_*` vars from `README.md` to `apps/api/.env`.
|
||||
- **Pre-existing lint/test issues (not environment problems):** `pnpm --filter @cio/api lint` fails (missing ESLint v9 `eslint.config.*`); `pnpm --filter @cio/dashboard lint` runs but reports pre-existing errors; api `vitest run` passes 61 tests but 5 files fail to load `@cio/core/services/*/*` subpaths (Vite nested-wildcard exports quirk; Node resolves them fine); `pnpm --filter @cio/dashboard test` (jest) fails to parse `jest.config.ts`. The pre-commit gate `pnpm format:check` passes.
|
||||
- The optional `@cio/storybook` build fails on an unresolved `@lucide/svelte/icons/bot` import; it does not affect api/dashboard.
|
||||
|
||||
### Deployment mode: self-hosted vs cloud (affects which features you can test)
|
||||
A single flag, `PUBLIC_IS_SELFHOSTED`, switches the whole product between **self-hosted** and **cloud** behavior. It is read in two places: the dashboard via `$env/static/public` (e.g. `apps/dashboard/src/lib/features/app/layout-setup.ts`) and the API/db layer via `@cio/core/config/env` (e.g. `apps/api/src/services/license.ts`, `apps/api/src/middlewares/license.ts`, `apps/api/src/services/onboarding.ts`). `FEATURE_AUDIT.md` §5 is the source-of-truth feature-by-feature map.
|
||||
|
||||
**This VM's local env is configured for CLOUD mode** (`PUBLIC_IS_SELFHOSTED=false` in both `apps/dashboard/.env` and `apps/api/.env`) so multi-tenant and all license-gated features are testable. The README contributor default is self-hosted; to switch this VM back, set `PUBLIC_IS_SELFHOSTED=true` in both files (or remove it from `apps/api/.env`) and restart both dev servers.
|
||||
|
||||
- **Self-hosted (`PUBLIC_IS_SELFHOSTED=true`)** — the README contributor default:
|
||||
- Single organization, single domain: creating a 2nd org is blocked (`onboarding.ts`), the "add org" UI is hidden, and the org is auto-assigned a `selfhosted` `ENTERPRISE` plan.
|
||||
- Enterprise features `sso`, `token-auth`, `no-tracking` are gated behind `LICENSE_KEY`, verified against the external `https://enterprise-api.classroomio.dev`. Without a key, `requireLicense` returns 403.
|
||||
- Polar billing and PostHog/Umami analytics are off.
|
||||
- **Cloud (`PUBLIC_IS_SELFHOSTED` unset or `false`)**:
|
||||
- Multi-tenant: multiple orgs per user, "add org" shown; org is resolved from the **subdomain** (`acme.<PRIVATE_APP_HOST>`) or a custom domain. Locally there are no subdomains, so an org public site is simulated with the `?org=<siteName>` query param (sets the `_orgSiteName` cookie — see `layout-setup.ts`).
|
||||
- The license gate is a **no-op**, so all enterprise features are unlocked **without** a `LICENSE_KEY` — i.e. cloud mode is the easiest way to exercise SSO/token-auth/multi-org in dev.
|
||||
- Polar billing and analytics activate but are themselves gated by their own keys (absent keys just no-op), so they don't block startup.
|
||||
|
||||
Non-obvious gotcha: the README local-dev setup only sets `PUBLIC_IS_SELFHOSTED=true` in `apps/dashboard/.env`; `apps/api/.env` leaves it unset, so the **API behaves as cloud** (license no-op, 2nd org allowed) while the **dashboard UI is self-hosted**. To exercise a coherent mode, set the flag the same way in both files. The flag is read at process start, so restart the dev servers after editing it.
|
||||
|
||||
### Seeded tenants (good for multi-tenant testing)
|
||||
The seed (`packages/db/src/utils/seed/organizationmember.ts`) creates **three independent organizations**, each with its own admin + student. All accounts use password `123456`:
|
||||
|
||||
| Org (siteName) | Admin | Student | Example courses |
|
||||
|---|---|---|---|
|
||||
| Udemy Test (`udemy-test`) | `[email protected]` | `[email protected]` | Modern Web Development with React; Getting started with MVC; Data Science with Python and Pandas |
|
||||
| Coursera Test (`coursera-test`) | `[email protected]` | `[email protected]` | SOC 2 Security Basics; HIPAA Awareness 2026 |
|
||||
| Skillshare Test (`skillshare-test`) | `[email protected]` | `[email protected]` | Product Management Fundamentals |
|
||||
|
||||
In cloud mode you can demonstrate tenant isolation on the single local instance by visiting each org's public catalog via the `?org=<siteName>` param, e.g. `http://localhost:5173/?org=coursera-test` vs `?org=udemy-test` vs `?org=skillshare-test` — each renders its own branded catalog and courses. (Note: a user enrolled in courses across orgs becomes a member of multiple tenants, so after login the dashboard may open whichever org that user most recently used.)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or-automation-key';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
@@ -44,7 +45,7 @@ export const tagsRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to fetch public tags');
|
||||
}
|
||||
})
|
||||
.get('/', authMiddleware, orgAdminMiddleware, async (c) => {
|
||||
.get('/', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const groups = await getOrganizationTagGroups(orgId);
|
||||
|
||||
+27
-8
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
@@ -43,12 +41,22 @@
|
||||
}: Props = $props();
|
||||
|
||||
const showPublicCourseLinks = $derived(isPublished && courseType === 'PUBLIC' && slug.trim().length > 0);
|
||||
const courseSettingsPath = $derived(resolve(`/courses/${id}/settings`, {}));
|
||||
|
||||
function redirect(url: string) {
|
||||
goto(resolve(url, {}));
|
||||
}
|
||||
|
||||
function scrollToSettingsSection(sectionId: string) {
|
||||
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function handleShareCourse() {
|
||||
if (page.url.pathname === courseSettingsPath) {
|
||||
scrollToSettingsSection('share');
|
||||
return;
|
||||
}
|
||||
|
||||
redirect(`/courses/${id}/settings#share`);
|
||||
}
|
||||
|
||||
@@ -57,13 +65,29 @@
|
||||
}
|
||||
|
||||
function handlePublishCourse() {
|
||||
if (page.url.pathname === courseSettingsPath) {
|
||||
scrollToSettingsSection('publish');
|
||||
return;
|
||||
}
|
||||
|
||||
redirect(`/courses/${id}/settings#publish`);
|
||||
}
|
||||
|
||||
function handleDeleteCourse() {
|
||||
if (page.url.pathname === courseSettingsPath) {
|
||||
scrollToSettingsSection('delete');
|
||||
return;
|
||||
}
|
||||
|
||||
const isOnOrgCoursesList = /\/org\/[^/]+\/courses\/?$/.test(page.url.pathname);
|
||||
if (isOnOrgCoursesList) {
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
return;
|
||||
}
|
||||
|
||||
redirect(`/courses/${id}/settings#delete`);
|
||||
}
|
||||
|
||||
function handleCloneCourse() {
|
||||
@@ -92,17 +116,14 @@
|
||||
|
||||
{#if lmsPublicQuickOnly}
|
||||
<DropdownMenu.Item onclick={handleViewCourseSite}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.view_course_site')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#if includeViewAsStudent}
|
||||
<DropdownMenu.Item onclick={() => onViewAsStudent?.()}>
|
||||
<EyeIcon size={16} class="mr-2" />
|
||||
{$t('course.header.view_as_student')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
@@ -110,12 +131,10 @@
|
||||
|
||||
{#if isPublished}
|
||||
<DropdownMenu.Item onclick={handleViewCourseSite}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.view_course_site')}
|
||||
</DropdownMenu.Item>
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
@@ -307,6 +307,17 @@
|
||||
loadCourseTags(courseId);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sectionId = $page.url.hash.replace('#', '').trim();
|
||||
if (!sectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
});
|
||||
|
||||
const selectedTagChips = $derived.by(() => {
|
||||
const allTags = tagApi.tagGroups.flatMap((group) =>
|
||||
group.tags.map((tag) => ({
|
||||
|
||||
Reference in New Issue
Block a user