feat: implement automation feature

This commit is contained in:
Rotimi Best
2026-03-18 23:39:12 +01:00
parent 576d0366de
commit ef4424e267
121 changed files with 4544 additions and 1055 deletions
+10 -1
View File
@@ -42,9 +42,18 @@ PRIVATE_SERVER_URL=http://api:3081
# When set, invite emails and other links point here instead of app.classroomio.com # When set, invite emails and other links point here instead of app.classroomio.com
DASHBOARD_ORIGIN=http://localhost:3082 DASHBOARD_ORIGIN=http://localhost:3082
# CSP: external domains allowed by Content-Security-Policy (required when self-hosted) # CSP: external domains allowed by Content-Security-Policy.
# These are runtime env vars — set them when running the container (no rebuild needed).
# Comma-separated. Include your CDN, API, video, and font domains. # Comma-separated. Include your CDN, API, video, and font domains.
# ALLOWED_EXTERNAL_DOMAINS overrides all per-directive vars below.
ALLOWED_EXTERNAL_DOMAINS= ALLOWED_EXTERNAL_DOMAINS=
# Per-directive overrides (optional, comma-separated):
# CSP_SCRIPT_SRC_DOMAINS=
# CSP_STYLE_SRC_DOMAINS=
# CSP_CONNECT_SRC_DOMAINS=
# CSP_FRAME_SRC_DOMAINS=
# CSP_FONT_SRC_DOMAINS=
# CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Optional — Email (pick one approach, or skip) # Optional — Email (pick one approach, or skip)
@@ -0,0 +1,80 @@
import { Context, Next } from 'hono';
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
import { ErrorCodes } from '@api/utils/errors';
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { courseMemberMiddleware } from './course-member';
export const courseMemberOrAutomationKeyMiddleware =
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
async (c: Context, next: Next) => {
try {
const automationKey = c.get('automationKey');
if (!automationKey) {
return courseMemberMiddleware(c, next);
}
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
return c.json(
{
success: false,
error: 'Automation key is missing required scopes',
code: ErrorCodes.FORBIDDEN
},
403
);
}
const courseId = c.req.param('courseId') || c.req.query('courseId');
if (!courseId) {
return c.json(
{
success: false,
error: 'Course ID is required',
code: 'COURSE_ID_REQUIRED'
},
400
);
}
const courseOrgId = await getCourseOrganizationId(courseId);
if (!courseOrgId) {
return c.json(
{
success: false,
error: 'Course not found',
code: ErrorCodes.COURSE_NOT_FOUND
},
404
);
}
if (courseOrgId !== automationKey.organizationId) {
return c.json(
{
success: false,
error: 'Invalid permissions',
code: ErrorCodes.UNAUTHORIZED
},
403
);
}
c.set('orgId', automationKey.organizationId);
c.set('actorId', automationKey.createdByProfileId);
return next();
} catch (error) {
console.error('Error in courseMemberOrAutomationKeyMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify course access',
code: 'COURSE_MEMBER_CHECK_FAILED'
},
500
);
}
};
@@ -0,0 +1,80 @@
import { Context, Next } from 'hono';
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
import { ErrorCodes } from '@api/utils/errors';
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { courseTeamMemberMiddleware } from './course-team-member';
export const courseTeamMemberOrAutomationKeyMiddleware =
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
async (c: Context, next: Next) => {
try {
const automationKey = c.get('automationKey');
if (!automationKey) {
return courseTeamMemberMiddleware(c, next);
}
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
return c.json(
{
success: false,
error: 'Automation key is missing required scopes',
code: ErrorCodes.FORBIDDEN
},
403
);
}
const courseId = c.req.param('courseId') || c.req.query('courseId');
if (!courseId) {
return c.json(
{
success: false,
error: 'Course ID is required',
code: 'COURSE_ID_REQUIRED'
},
400
);
}
const courseOrgId = await getCourseOrganizationId(courseId);
if (!courseOrgId) {
return c.json(
{
success: false,
error: 'Course not found',
code: ErrorCodes.COURSE_NOT_FOUND
},
404
);
}
if (courseOrgId !== automationKey.organizationId) {
return c.json(
{
success: false,
error: 'Invalid permissions',
code: ErrorCodes.UNAUTHORIZED
},
403
);
}
c.set('orgId', automationKey.organizationId);
c.set('actorId', automationKey.createdByProfileId);
return next();
} catch (error) {
console.error('Error in courseTeamMemberOrAutomationKeyMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify course team access',
code: 'COURSE_TEAM_MEMBER_CHECK_FAILED'
},
500
);
}
};
@@ -0,0 +1,45 @@
import { Context, Next } from 'hono';
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
import { ErrorCodes } from '@api/utils/errors';
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
import { orgMemberMiddleware } from './org-member';
export const orgMemberOrAutomationKeyMiddleware =
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
async (c: Context, next: Next) => {
try {
const automationKey = c.get('automationKey');
if (!automationKey) {
return orgMemberMiddleware(c, next);
}
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
return c.json(
{
success: false,
error: 'Automation key is missing required scopes',
code: ErrorCodes.FORBIDDEN
},
403
);
}
c.set('orgId', automationKey.organizationId);
c.set('userRole', null);
c.set('actorId', automationKey.createdByProfileId);
return next();
} catch (error) {
console.error('Error in orgMemberOrAutomationKeyMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify organization membership',
code: 'ORG_MEMBER_CHECK_FAILED'
},
500
);
}
};
+36 -2
View File
@@ -1,13 +1,47 @@
import { ZCourseContentDelete, ZCourseContentUpdate } from '@cio/utils/validation/course'; import { ZCourseContentDelete, ZCourseContentReorder, ZCourseContentUpdate } from '@cio/utils/validation/course';
import { deleteCourseContent, updateCourseContent } from '@api/services/course/content'; import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@api/services/course/content';
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { courseTeamMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-team-member-or-automation-key';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member'; import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const contentRouter = new Hono() export const contentRouter = new Hono()
.put(
'/reorder',
authOrAutomationKeyMiddleware,
courseTeamMemberOrAutomationKeyMiddleware(['course:write']),
zValidator('json', ZCourseContentReorder),
async (c) => {
try {
const courseId = c.req.param('courseId')!;
const automationKey = c.get('automationKey');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'reorder_course_content');
}
const result = await reorderCourseContent(courseId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'reorder_course_content', {
courseId,
sectionCount: payload.sections?.length ?? 0,
itemCount: payload.items?.length ?? 0
});
}
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to reorder course content');
}
}
)
.put('/', authMiddleware, courseTeamMemberMiddleware, zValidator('json', ZCourseContentUpdate), async (c) => { .put('/', authMiddleware, courseTeamMemberMiddleware, zValidator('json', ZCourseContentUpdate), async (c) => {
try { try {
const courseId = c.req.param('courseId')!; const courseId = c.req.param('courseId')!;
+44
View File
@@ -11,6 +11,7 @@ import {
ZCourseGetBySlugParam, ZCourseGetBySlugParam,
ZCourseGetParam, ZCourseGetParam,
ZCourseGetQuery, ZCourseGetQuery,
ZCourseLandingPageUpdate,
ZCourseProgressParam, ZCourseProgressParam,
ZCourseProgressQuery, ZCourseProgressQuery,
ZCourseUpdate, ZCourseUpdate,
@@ -30,9 +31,11 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag';
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { attendanceRouter } from '@api/routes/course/attendance'; import { attendanceRouter } from '@api/routes/course/attendance';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { cloneCourse } from '@api/services/course/clone'; import { cloneCourse } from '@api/services/course/clone';
import { contentRouter } from '@api/routes/course/content'; import { contentRouter } from '@api/routes/course/content';
import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseTeamMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-team-member-or-automation-key';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member'; import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { createRateLimiter } from '@api/middlewares/rate-limiter'; import { createRateLimiter } from '@api/middlewares/rate-limiter';
import { enrollInCourse } from '@api/services/course/invite'; import { enrollInCourse } from '@api/services/course/invite';
@@ -51,8 +54,10 @@ import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { paymentRequestRouter } from '@api/routes/course/payment-request'; import { paymentRequestRouter } from '@api/routes/course/payment-request';
import { presignRouter } from '@api/routes/course/presign'; import { presignRouter } from '@api/routes/course/presign';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { sectionRouter } from '@api/routes/course/section'; import { sectionRouter } from '@api/routes/course/section';
import { submissionRouter } from '@api/routes/course/submission'; import { submissionRouter } from '@api/routes/course/submission';
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
const enrollRateLimit = createRateLimiter({ const enrollRateLimit = createRateLimiter({
@@ -233,6 +238,45 @@ export const courseRouter = new Hono()
} }
} }
) )
/**
* PUT /course/:courseId/landing-page
* Updates landing-page-facing course fields such as copy, media, pricing, and reviews.
* Requires authentication and course team membership, or an automation key with course write scope.
*/
.put(
'/:courseId/landing-page',
authOrAutomationKeyMiddleware,
courseTeamMemberOrAutomationKeyMiddleware(['course:write']),
zValidator('param', ZCourseUpdateParam),
zValidator('json', ZCourseLandingPageUpdate),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const payload = c.req.valid('json');
const automationKey = c.get('automationKey');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_landing_page');
}
const result = await updateCourseLandingPageService(courseId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'update_course_landing_page', { courseId });
}
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update course landing page');
}
}
)
/** /**
* PUT /course/:courseId * PUT /course/:courseId
* Updates a course * Updates a course
+94 -16
View File
@@ -21,24 +21,43 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-member-or-automation-key';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { createSubmissionService, listExerciseSubmissionsOverview } from '@api/services/submission'; import { createSubmissionService, listExerciseSubmissionsOverview } from '@api/services/submission';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const exerciseRouter = new Hono() export const exerciseRouter = new Hono()
// Exercise CRUD routes // Exercise CRUD routes
.get('/', authMiddleware, courseMemberMiddleware, zValidator('query', ZExerciseListQuery), async (c) => { .get(
'/',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:read']),
zValidator('query', ZExerciseListQuery),
async (c) => {
try { try {
const courseId = c.req.param('courseId')!; const courseId = c.req.param('courseId')!;
const user = c.get('user')!; const user = c.get('user');
const automationKey = c.get('automationKey');
const { lessonId, sectionId } = c.req.valid('query'); const { lessonId, sectionId } = c.req.valid('query');
const exercises = await listExercises(courseId, { lessonId, sectionId }, user.id);
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'list_course_exercises');
}
const exercises = await listExercises(courseId, { lessonId, sectionId }, user?.id);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'list_course_exercises', { courseId, lessonId, sectionId });
}
return c.json({ success: true, data: exercises }, 200); return c.json({ success: true, data: exercises }, 200);
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to list exercises'); return handleError(c, error, 'Failed to list exercises');
} }
}) }
)
.get('/:exerciseId/submissions', authMiddleware, courseMemberMiddleware, async (c) => { .get('/:exerciseId/submissions', authMiddleware, courseMemberMiddleware, async (c) => {
try { try {
const courseId = c.req.param('courseId')!; const courseId = c.req.param('courseId')!;
@@ -51,28 +70,65 @@ export const exerciseRouter = new Hono()
return handleError(c, error, 'Failed to fetch exercise submissions overview'); return handleError(c, error, 'Failed to fetch exercise submissions overview');
} }
}) })
.get('/:exerciseId', authMiddleware, courseMemberMiddleware, zValidator('param', ZExerciseGetParam), async (c) => { .get(
'/:exerciseId',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:read']),
zValidator('param', ZExerciseGetParam),
async (c) => {
try { try {
const { exerciseId } = c.req.valid('param'); const { exerciseId } = c.req.valid('param');
const user = c.get('user')!; const user = c.get('user');
const exercise = await getExercise(exerciseId, undefined, user.id); const automationKey = c.get('automationKey');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_exercise');
}
const exercise = await getExercise(exerciseId, undefined, user?.id);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'get_course_exercise', {
courseId: c.req.param('courseId')!,
exerciseId
});
}
return c.json({ success: true, data: exercise }, 200); return c.json({ success: true, data: exercise }, 200);
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to fetch exercise'); return handleError(c, error, 'Failed to fetch exercise');
} }
}) }
.post('/', authMiddleware, courseMemberMiddleware, zValidator('json', ZExerciseCreate), async (c) => { )
.post(
'/',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
zValidator('json', ZExerciseCreate),
async (c) => {
try { try {
const data = c.req.valid('json'); const data = c.req.valid('json');
const automationKey = c.get('automationKey');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_exercise');
}
const exercise = await createExercise(data); const exercise = await createExercise(data);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'create_course_exercise', {
courseId: c.req.param('courseId')!,
exerciseId: exercise.id
});
}
return c.json({ success: true, data: exercise }, 201); return c.json({ success: true, data: exercise }, 201);
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to create exercise'); return handleError(c, error, 'Failed to create exercise');
} }
}) }
)
/** /**
* POST /course/:courseId/exercise/from-template * POST /course/:courseId/exercise/from-template
* Creates an exercise from a template * Creates an exercise from a template
@@ -80,14 +136,19 @@ export const exerciseRouter = new Hono()
*/ */
.post( .post(
'/from-template', '/from-template',
authMiddleware, authOrAutomationKeyMiddleware,
courseMemberMiddleware, courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
zValidator('json', ZExerciseFromTemplate), zValidator('json', ZExerciseFromTemplate),
async (c) => { async (c) => {
try { try {
const courseId = c.req.param('courseId')!; const courseId = c.req.param('courseId')!;
const automationKey = c.get('automationKey');
const { lessonId, sectionId, order, templateId } = c.req.valid('json'); const { lessonId, sectionId, order, templateId } = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_exercise_from_template');
}
// Fetch template from database // Fetch template from database
const template = await fetchTemplateById(templateId); const template = await fetchTemplateById(templateId);
if (!template) { if (!template) {
@@ -100,6 +161,14 @@ export const exerciseRouter = new Hono()
const exercise = await createExerciseFromTemplate(courseId, lessonId, sectionId, order, template); const exercise = await createExerciseFromTemplate(courseId, lessonId, sectionId, order, template);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'create_course_exercise_from_template', {
courseId,
exerciseId: exercise.id,
templateId
});
}
return c.json({ success: true, data: exercise }, 201); return c.json({ success: true, data: exercise }, 201);
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to create exercise from template'); return handleError(c, error, 'Failed to create exercise from template');
@@ -108,8 +177,8 @@ export const exerciseRouter = new Hono()
) )
.put( .put(
'/:exerciseId', '/:exerciseId',
authMiddleware, authOrAutomationKeyMiddleware,
courseMemberMiddleware, courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
zValidator('param', ZExerciseGetParam), zValidator('param', ZExerciseGetParam),
zValidator('json', ZExerciseUpdate), zValidator('json', ZExerciseUpdate),
async (c) => { async (c) => {
@@ -117,9 +186,14 @@ export const exerciseRouter = new Hono()
const { exerciseId } = c.req.valid('param'); const { exerciseId } = c.req.valid('param');
const data = c.req.valid('json'); const data = c.req.valid('json');
const courseId = c.req.param('courseId')!; const courseId = c.req.param('courseId')!;
const user = c.get('user')!; const user = c.get('user');
const automationKey = c.get('automationKey');
if (data.isUnlocked !== undefined) { if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_exercise');
}
if (!automationKey && user && data.isUnlocked !== undefined) {
const isAuthorized = await isCourseTeamMemberOrOrgAdmin(courseId, user.id); const isAuthorized = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
if (!isAuthorized) { if (!isAuthorized) {
@@ -136,6 +210,10 @@ export const exerciseRouter = new Hono()
const exercise = await updateExerciseService(exerciseId, data); const exercise = await updateExerciseService(exerciseId, data);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'update_course_exercise', { courseId, exerciseId });
}
return c.json({ success: true, data: exercise }, 200); return c.json({ success: true, data: exercise }, 200);
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to update exercise'); return handleError(c, error, 'Failed to update exercise');
@@ -1,5 +1,6 @@
import { import {
ZCreateOrganizationApiKey, ZCreateOrganizationApiKey,
ZOrganizationAutomationUsageQuery,
ZListOrganizationApiKeysQuery, ZListOrganizationApiKeysQuery,
ZOrganizationApiKeyParam ZOrganizationApiKeyParam
} from '@cio/utils/validation/organization'; } from '@cio/utils/validation/organization';
@@ -9,6 +10,7 @@ import {
revokeOrganizationApiKeyService, revokeOrganizationApiKeyService,
rotateOrganizationApiKeyService rotateOrganizationApiKeyService
} from '@api/services/organization/automation-key'; } from '@api/services/organization/automation-key';
import { getOrganizationAutomationUsageSummaryService } from '@api/services/organization/automation-usage';
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
@@ -17,6 +19,23 @@ import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const automationRouter = new Hono() export const automationRouter = new Hono()
.get(
'/usage',
authMiddleware,
orgAdminMiddleware,
zValidator('query', ZOrganizationAutomationUsageQuery),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const query = c.req.valid('query');
const usage = await getOrganizationAutomationUsageSummaryService(orgId, query.type);
return c.json({ success: true, data: usage }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load organization automation usage');
}
}
)
.get('/keys', authMiddleware, orgAdminMiddleware, zValidator('query', ZListOrganizationApiKeysQuery), async (c) => { .get('/keys', authMiddleware, orgAdminMiddleware, zValidator('query', ZListOrganizationApiKeysQuery), async (c) => {
try { try {
const orgId = c.req.header('cio-org-id')!; const orgId = c.req.header('cio-org-id')!;
@@ -1,15 +1,24 @@
import { ZAutomationDraftTagAssignment, ZAutomationDraftTagParam } from '@cio/utils/validation/tag';
import { import {
ZCourseImportCourseParam,
ZCourseImportDraftCreate, ZCourseImportDraftCreate,
ZCourseImportDraftCreateFromCourse,
ZCourseImportDraftGetParam, ZCourseImportDraftGetParam,
ZCourseImportDraftPublish, ZCourseImportDraftPublish,
ZCourseImportDraftPublishToCourse,
ZCourseImportDraftUpdate ZCourseImportDraftUpdate
} from '@cio/utils/validation/course-import'; } from '@cio/utils/validation/course-import';
import { import {
createCourseImportDraftFromCourseService,
createCourseImportDraftService, createCourseImportDraftService,
getCourseImportDraftService, getCourseImportDraftService,
getCourseImportStructureService,
publishCourseImportDraftService, publishCourseImportDraftService,
publishCourseImportDraftToExistingCourseService,
updateCourseImportDraftTagsService,
updateCourseImportDraftService updateCourseImportDraftService
} from '@api/services/course-import/course-import'; } from '@api/services/course-import/course-import';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
@@ -18,6 +27,33 @@ import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const courseImportRouter = new Hono() export const courseImportRouter = new Hono()
.get(
'/courses/:courseId/structure',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:read']),
zValidator('param', ZCourseImportCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const { courseId } = c.req.valid('param');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_structure');
}
const structure = await getCourseImportStructureService(orgId, courseId);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'get_course_structure', { courseId });
}
return c.json({ success: true, data: structure }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch course structure');
}
}
)
.post( .post(
'/drafts', '/drafts',
authOrAutomationKeyMiddleware, authOrAutomationKeyMiddleware,
@@ -27,16 +63,56 @@ export const courseImportRouter = new Hono()
try { try {
const orgId = c.get('orgId')!; const orgId = c.get('orgId')!;
const actorId = c.get('actorId')!; const actorId = c.get('actorId')!;
const automationKey = c.get('automationKey');
const payload = c.req.valid('json'); const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_draft');
}
const draft = await createCourseImportDraftService(orgId, actorId, payload); const draft = await createCourseImportDraftService(orgId, actorId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'create_course_draft', { draftId: draft.id });
}
return c.json({ success: true, data: draft }, 201); return c.json({ success: true, data: draft }, 201);
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to create course import draft'); return handleError(c, error, 'Failed to create course import draft');
} }
} }
) )
.post(
'/drafts/from-course',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:create']),
zValidator('json', ZCourseImportDraftCreateFromCourse),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId')!;
const automationKey = c.get('automationKey');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_draft_from_course');
}
const draft = await createCourseImportDraftFromCourseService(orgId, actorId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'create_course_draft_from_course', {
draftId: draft.id,
courseId: payload.courseId
});
}
return c.json({ success: true, data: draft }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create course draft from existing course');
}
}
)
.get( .get(
'/drafts/:draftId', '/drafts/:draftId',
authOrAutomationKeyMiddleware, authOrAutomationKeyMiddleware,
@@ -45,16 +121,54 @@ export const courseImportRouter = new Hono()
async (c) => { async (c) => {
try { try {
const orgId = c.get('orgId')!; const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const { draftId } = c.req.valid('param'); const { draftId } = c.req.valid('param');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_draft');
}
const draft = await getCourseImportDraftService(orgId, draftId); const draft = await getCourseImportDraftService(orgId, draftId);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'get_course_draft', { draftId });
}
return c.json({ success: true, data: draft }, 200); return c.json({ success: true, data: draft }, 200);
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to fetch course import draft'); return handleError(c, error, 'Failed to fetch course import draft');
} }
} }
) )
.put(
'/drafts/:draftId/tags',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:update']),
zValidator('param', ZAutomationDraftTagParam),
zValidator('json', ZAutomationDraftTagAssignment),
async (c) => {
try {
const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const { draftId } = c.req.valid('param');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'tag_course_draft');
}
const draft = await updateCourseImportDraftTagsService(orgId, draftId, payload.tagNames, payload.mode);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'tag_course_draft', { draftId });
}
return c.json({ success: true, data: draft }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update course draft tags');
}
}
)
.put( .put(
'/drafts/:draftId', '/drafts/:draftId',
authOrAutomationKeyMiddleware, authOrAutomationKeyMiddleware,
@@ -64,11 +178,20 @@ export const courseImportRouter = new Hono()
async (c) => { async (c) => {
try { try {
const orgId = c.get('orgId')!; const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const { draftId } = c.req.valid('param'); const { draftId } = c.req.valid('param');
const payload = c.req.valid('json'); const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_draft');
}
const draft = await updateCourseImportDraftService(orgId, draftId, payload); const draft = await updateCourseImportDraftService(orgId, draftId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'update_course_draft', { draftId });
}
return c.json({ success: true, data: draft }, 200); return c.json({ success: true, data: draft }, 200);
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to update course import draft'); return handleError(c, error, 'Failed to update course import draft');
@@ -85,14 +208,58 @@ export const courseImportRouter = new Hono()
try { try {
const orgId = c.get('orgId')!; const orgId = c.get('orgId')!;
const actorId = c.get('actorId')!; const actorId = c.get('actorId')!;
const automationKey = c.get('automationKey');
const { draftId } = c.req.valid('param'); const { draftId } = c.req.valid('param');
const payload = c.req.valid('json'); const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'publish_course_draft');
}
const result = await publishCourseImportDraftService(orgId, actorId, draftId, payload); const result = await publishCourseImportDraftService(orgId, actorId, draftId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'publish_course_draft', {
draftId,
courseId: result.courseId
});
}
return c.json({ success: true, data: result }, 201); return c.json({ success: true, data: result }, 201);
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to publish course import draft'); return handleError(c, error, 'Failed to publish course import draft');
} }
} }
)
.post(
'/drafts/:draftId/publish-existing-course',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:publish']),
zValidator('param', ZCourseImportDraftGetParam),
zValidator('json', ZCourseImportDraftPublishToCourse),
async (c) => {
try {
const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const { draftId } = c.req.valid('param');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'publish_course_draft_to_existing_course');
}
const result = await publishCourseImportDraftToExistingCourseService(orgId, draftId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'publish_course_draft_to_existing_course', {
draftId,
courseId: payload.courseId
});
}
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to publish course draft to existing course');
}
}
); );
@@ -37,15 +37,19 @@ import { TOrganization } from '@db/types';
import { automationRouter } from '@api/routes/organization/automation'; import { automationRouter } from '@api/routes/organization/automation';
import { assetsRouter } from '@api/routes/organization/assets'; import { assetsRouter } from '@api/routes/organization/assets';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { courseImportRouter } from '@api/routes/organization/course-import'; import { courseImportRouter } from '@api/routes/organization/course-import';
import { getLMSExercisesService } from '@api/services/exercise'; import { getLMSExercisesService } from '@api/services/exercise';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
import { inviteTeamMembers } from '@api/services/organization/invite'; import { inviteTeamMembers } from '@api/services/organization/invite';
import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
import { quizRouter } from '@api/routes/organization/quiz'; import { quizRouter } from '@api/routes/organization/quiz';
import { tagsRouter } from '@api/routes/organization/tags'; import { tagsRouter } from '@api/routes/organization/tags';
import { ROLE } from '@cio/utils/constants';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono() export const organizationRouter = new Hono()
@@ -287,17 +291,18 @@ export const organizationRouter = new Hono()
*/ */
.get( .get(
'/courses', '/courses',
authMiddleware, authOrAutomationKeyMiddleware,
orgMemberMiddleware, orgMemberOrAutomationKeyMiddleware(['course:read']),
zValidator('query', ZGetOrganizationCoursesQuery), zValidator('query', ZGetOrganizationCoursesQuery),
async (c) => { async (c) => {
try { try {
const user = c.get('user')!; const user = c.get('user');
const orgId = c.get('orgId'); const orgId = c.get('orgId');
const userRole = c.get('userRole'); const userRole = c.get('userRole');
const automationKey = c.get('automationKey');
const { tags } = c.req.valid('query'); const { tags } = c.req.valid('query');
if (!orgId || userRole === null) { if (!orgId) {
return c.json( return c.json(
{ {
success: false, success: false,
@@ -308,22 +313,25 @@ export const organizationRouter = new Hono()
); );
} }
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'list_org_courses');
}
const tagSlugs = tags const tagSlugs = tags
?.split(',') ?.split(',')
.map((value) => value.trim()) .map((value) => value.trim())
.filter(Boolean); .filter(Boolean);
console.log('user', user);
console.log('userRole', userRole);
console.log('orgId', orgId);
console.log('tagSlugs', tagSlugs);
const result = await getOrganizationCourses( const result = await getOrganizationCourses(
orgId, orgId,
user.id, user?.id ?? automationKey?.createdByProfileId ?? '',
userRole, userRole ?? ROLE.ADMIN,
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined
); );
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'list_org_courses', { tags: tagSlugs });
}
return c.json( return c.json(
{ {
success: true, success: true,
+38
View File
@@ -1,4 +1,5 @@
import { import {
ZAutomationCourseTagAssignment,
ZTagCreate, ZTagCreate,
ZTagGroupCreate, ZTagGroupCreate,
ZTagGroupParam, ZTagGroupParam,
@@ -7,6 +8,7 @@ import {
ZTagUpdate ZTagUpdate
} from '@cio/utils/validation/tag'; } from '@cio/utils/validation/tag';
import { import {
assignTagsToCoursesByName,
createOrganizationTag, createOrganizationTag,
createOrganizationTagGroup, createOrganizationTagGroup,
getPublicOrganizationTagGroups, getPublicOrganizationTagGroups,
@@ -18,8 +20,11 @@ import { ZGetCoursesBySiteName } from '@cio/utils/validation/organization';
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { orgAdminMiddleware } from '@api/middlewares/org-admin';
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'; import { zValidator } from '@hono/zod-validator';
export const tagsRouter = new Hono() export const tagsRouter = new Hono()
@@ -72,6 +77,39 @@ export const tagsRouter = new Hono()
return handleError(c, error, 'Failed to create tag group'); return handleError(c, error, 'Failed to create tag group');
} }
}) })
.put(
'/courses/assign',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course:tag:write']),
zValidator('json', ZAutomationCourseTagAssignment),
async (c) => {
try {
const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const data = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'tag_courses');
}
const result = await assignTagsToCoursesByName(orgId, data);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'tag_courses', { courseIds: data.courseIds });
}
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to assign course tags');
}
}
)
.put( .put(
'/groups/:groupId', '/groups/:groupId',
authMiddleware, authMiddleware,
@@ -1,22 +1,37 @@
import { AppError, ErrorCodes } from '@api/utils/errors'; import { AppError, ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { createCourse, updateCourse } from '@api/services/course/course';
import { createCourseSection, listCourseSections, updateCourseSectionService } from '@api/services/course/section';
import { createLesson, listLessons, updateLessonService } from '@api/services/lesson';
import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language';
import { import {
createCourseImportDraft, createCourseImportDraft,
getCourseImportDraftById, getCourseImportDraftById,
getCourseImportDraftByIdempotencyKey, getCourseImportDraftByIdempotencyKey,
updateCourseImportDraft updateCourseImportDraft
} from '@cio/db/queries/course-import'; } from '@cio/db/queries/course-import';
import type { TCourseImportDraft, TLocale } from '@db/types'; import { getCourseById } from '@cio/db/queries/course';
import type { import { getLessonLanguagesByLessonIds } from '@cio/db/queries/lesson';
TCourseImportDraftCreate, import { getOrganizationById } from '@cio/db/queries/organization';
TCourseImportDraftPayload, import { getCourseTagsForOrganization } from '@cio/db/queries/tag';
TCourseImportDraftPublish, import type { TCourse, TCourseImportDraft, TLessonLanguage, TLocale, TOrganization } from '@db/types';
TCourseImportDraftUpdate import {
type TCourseImportDraftCreate,
type TCourseImportDraftCreateFromCourse,
type TCourseImportDraftPayload,
type TCourseImportDraftPublish,
type TCourseImportDraftPublishToCourse,
type TCourseImportDraftUpdate,
ZCourseImportDraftPayload,
ZCourseImportWarning
} from '@cio/utils/validation/course-import'; } from '@cio/utils/validation/course-import';
import { ZCourseImportDraftPayload } from '@cio/utils/validation/course-import'; import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { createCourse, updateCourse } from '@api/services/course/course'; import { assignCourseTagsByName } from '@api/services/tag';
import { createCourseSection } from '@api/services/course/section'; import { db } from '@cio/db/drizzle';
import { createLesson } from '@api/services/lesson'; import * as schema from '@cio/db/schema';
import { upsertLessonLanguageService } from '@api/services/lesson-language'; import { generateSlug } from '@cio/utils/functions';
import { eq } from 'drizzle-orm';
type DraftSummary = { type DraftSummary = {
sectionCount: number; sectionCount: number;
@@ -25,6 +40,30 @@ type DraftSummary = {
warningCount: number; warningCount: number;
}; };
type CourseStructureSnapshot = {
courseId: string;
draft: TCourseImportDraftPayload;
};
type PublishDraftResult = {
courseId: string;
courseUrl: string;
bannerImageUrl: string | null;
tagNames: string[];
createdSections: number;
createdLessons: number;
localeCount: number;
};
type PublishDraftToExistingCourseResult = PublishDraftResult & {
updatedSections: number;
updatedLessons: number;
createdLessonLanguages: number;
updatedLessonLanguages: number;
untouchedSections: number;
untouchedLessons: number;
};
function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary { function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
return { return {
sectionCount: draft.sections.length, sectionCount: draft.sections.length,
@@ -34,10 +73,314 @@ function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
}; };
} }
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
let suffix = 0;
let candidate = baseSlug;
while (true) {
const existing = await db
.select({ id: schema.course.id })
.from(schema.course)
.where(eq(schema.course.slug, candidate))
.limit(1);
if (existing.length === 0) {
return candidate;
}
suffix += 1;
candidate = `${baseSlug}-${suffix}`;
}
}
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
const [course] = await getCourseById(courseId);
if (course?.slug) {
return course.slug;
}
const baseSlug = generateSlug(title, { fallback: 'course' });
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
await updateCourse(courseId, { slug: uniqueSlug });
return uniqueSlug;
}
function buildCourseBaseUrl(organization: TOrganization) {
if (organization.customDomain && organization.isCustomDomainVerified) {
return `https://${organization.customDomain}`;
}
return getDashboardBaseUrl(organization.siteName ?? undefined);
}
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
const organization = await getOrganizationById(organizationId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
}
const slug = await ensureCourseSlug(courseId, title);
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
if (!env.UNSPLASH_API_KEY) {
return null;
}
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
const response = await fetch(
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
{ method: 'GET' }
);
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
results?: Array<{
urls?: {
regular?: string;
};
}>;
};
const photos = data.results ?? [];
if (photos.length === 0) {
return null;
}
const photo = photos[Math.floor(Math.random() * photos.length)];
return photo?.urls?.regular ?? null;
}
async function resolvePublishBannerImage(
title: string,
overrides: Pick<TCourseImportDraftPublish, 'bannerImageQuery' | 'bannerImageUrl' | 'generateBannerImage'>
) {
if (overrides.bannerImageUrl) {
return overrides.bannerImageUrl;
}
if (!overrides.generateBannerImage && !overrides.bannerImageQuery) {
return null;
}
try {
return await resolveUnsplashBannerImage(title, overrides.bannerImageQuery);
} catch (error) {
console.error('resolvePublishBannerImage error:', error);
return null;
}
}
async function maybeApplyCourseBannerImage(courseId: string, imageUrl: string | null) {
if (!imageUrl) {
return null;
}
await updateCourse(courseId, {
logo: imageUrl,
bannerImage: imageUrl
});
return imageUrl;
}
function normalizeDraftTagNames(tagNames: string[]) {
return Array.from(new Map(tagNames.map((tag) => [tag.trim().toLowerCase(), tag.trim()])).values()).filter(Boolean);
}
function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload { function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload {
return ZCourseImportDraftPayload.parse(record.draft); return ZCourseImportDraftPayload.parse(record.draft);
} }
async function assertCourseBelongsToOrganization(orgId: string, courseId: string) {
const courseOrgId = await getCourseOrganizationId(courseId);
if (!courseOrgId) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
if (courseOrgId !== orgId) {
throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403);
}
}
async function getCourseRecord(orgId: string, courseId: string): Promise<TCourse> {
await assertCourseBelongsToOrganization(orgId, courseId);
const [course] = await getCourseById(courseId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
return course;
}
function inferDraftLocale(languages: TLessonLanguage[]): TLocale {
const locale = languages[0]?.locale;
return (locale ?? 'en') as TLocale;
}
function getPrimaryLessonContent(
lessonExternalId: string,
lessonLanguages: TCourseImportDraftPayload['lessonLanguages'],
preferredLocale: TLocale
) {
const localized = lessonLanguages.find(
(lessonLanguage) =>
lessonLanguage.lessonExternalId === lessonExternalId && lessonLanguage.locale === preferredLocale
);
if (localized) {
return localized.content;
}
return lessonLanguages.find((lessonLanguage) => lessonLanguage.lessonExternalId === lessonExternalId)?.content;
}
async function buildCourseStructureSnapshot(orgId: string, courseId: string): Promise<CourseStructureSnapshot> {
const course = await getCourseRecord(orgId, courseId);
const [sections, lessons, tags] = await Promise.all([
listCourseSections(courseId),
listLessons(courseId),
getCourseTagsForOrganization(orgId, courseId)
]);
if (lessons.length === 0) {
throw new AppError(
'Course must contain at least one lesson before it can be imported into a draft',
ErrorCodes.VALIDATION_ERROR,
400
);
}
const lessonIds = lessons.map((lesson) => lesson.id);
const lessonLanguages = await getLessonLanguagesByLessonIds(lessonIds);
const fallbackLocale = inferDraftLocale(lessonLanguages);
const warnings: TCourseImportDraftPayload['warnings'] = [];
const normalizedSections = [...sections]
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
.map((section) => ({
externalId: section.id,
title: section.title ?? 'Untitled Section',
order: Number(section.order ?? 0)
}));
let sectionReferenceMap = new Map(normalizedSections.map((section) => [section.externalId, section.externalId]));
if (normalizedSections.length === 0 || lessons.some((lesson) => !lesson.sectionId)) {
const syntheticSectionId = `synthetic:${courseId}:ungrouped`;
normalizedSections.push({
externalId: syntheticSectionId,
title: 'Ungrouped',
order: normalizedSections.length
});
sectionReferenceMap = new Map(normalizedSections.map((section) => [section.externalId, section.externalId]));
warnings.push(
ZCourseImportWarning.parse({
code: 'UNGROUPED_CONTENT_NORMALIZED',
message: 'Some lessons were ungrouped. A synthetic section was added to the draft.',
severity: 'warning'
})
);
}
const normalizedLessons = [...lessons]
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
.map((lesson) => ({
externalId: lesson.id,
sectionExternalId:
lesson.sectionId && sectionReferenceMap.has(lesson.sectionId)
? lesson.sectionId
: normalizedSections[normalizedSections.length - 1]!.externalId,
title: lesson.title,
order: Number(lesson.order ?? 0),
isUnlocked: lesson.isUnlocked ?? undefined,
public: lesson.public ?? undefined
}));
const normalizedLessonLanguages: TCourseImportDraftPayload['lessonLanguages'] = [];
for (const lesson of lessons) {
const localized = lessonLanguages.filter((lessonLanguage) => lessonLanguage.lessonId === lesson.id);
if (localized.length > 0) {
const validLocalized = localized
.map((lessonLanguage) => ({
lessonExternalId: lesson.id,
locale: lessonLanguage.locale as TLocale,
content: lessonLanguage.content?.trim() ?? ''
}))
.filter((lessonLanguage) => lessonLanguage.content.length > 0);
if (validLocalized.length > 0) {
normalizedLessonLanguages.push(...validLocalized);
continue;
}
warnings.push(
ZCourseImportWarning.parse({
code: 'EMPTY_LOCALE_CONTENT_SKIPPED',
message: `Lesson "${lesson.title}" had empty localized content and required a fallback.`,
severity: 'warning'
})
);
}
if (lesson.note?.trim()) {
normalizedLessonLanguages.push({
lessonExternalId: lesson.id,
locale: fallbackLocale,
content: lesson.note
});
warnings.push(
ZCourseImportWarning.parse({
code: 'LEGACY_NOTE_FALLBACK',
message: `Lesson "${lesson.title}" used legacy note content because no lesson_language rows were found.`,
severity: 'info'
})
);
}
}
if (normalizedLessonLanguages.length === 0) {
throw new AppError(
'Course lessons must contain note or localized content before they can be imported into a draft',
ErrorCodes.VALIDATION_ERROR,
400
);
}
const draft = ZCourseImportDraftPayload.parse({
course: {
title: course.title,
description: course.description,
type: course.type,
locale: fallbackLocale,
metadata: course.metadata
},
tags: tags.map((tag) => tag.name),
sections: normalizedSections,
lessons: normalizedLessons,
lessonLanguages: normalizedLessonLanguages,
sourceReferences: [
{
type: 'course',
label: course.title
}
],
warnings
});
return {
courseId,
draft
};
}
export async function createCourseImportDraftService(orgId: string, profileId: string, data: TCourseImportDraftCreate) { export async function createCourseImportDraftService(orgId: string, profileId: string, data: TCourseImportDraftCreate) {
try { try {
if (data.idempotencyKey) { if (data.idempotencyKey) {
@@ -76,6 +419,48 @@ export async function createCourseImportDraftService(orgId: string, profileId: s
} }
} }
export async function createCourseImportDraftFromCourseService(
orgId: string,
profileId: string,
data: TCourseImportDraftCreateFromCourse
) {
const snapshot = await buildCourseStructureSnapshot(orgId, data.courseId);
return createCourseImportDraftService(orgId, profileId, {
sourceType: 'course',
idempotencyKey: data.idempotencyKey,
summary: {
sourceCourseId: data.courseId,
...(data.summary ?? {})
},
sourceArtifacts: [
{
type: 'course',
courseId: data.courseId,
label: snapshot.draft.course.title
},
...(data.sourceArtifacts ?? [])
],
draft: snapshot.draft
});
}
export async function getCourseImportStructureService(orgId: string, courseId: string) {
try {
return await buildCourseStructureSnapshot(orgId, courseId);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch course structure',
ErrorCodes.COURSE_IMPORT_FETCH_FAILED,
500
);
}
}
export async function getCourseImportDraftService(orgId: string, draftId: string) { export async function getCourseImportDraftService(orgId: string, draftId: string) {
try { try {
const draft = await getCourseImportDraftById(orgId, draftId); const draft = await getCourseImportDraftById(orgId, draftId);
@@ -138,18 +523,52 @@ export async function updateCourseImportDraftService(orgId: string, draftId: str
} }
} }
export async function updateCourseImportDraftTagsService(
orgId: string,
draftId: string,
tagNames: string[],
mode: 'merge' | 'replace'
) {
const existing = await getCourseImportDraftService(orgId, draftId);
if (existing.status === 'PUBLISHED') {
throw new AppError('Published drafts cannot be edited', ErrorCodes.FORBIDDEN, 403);
}
const draft = parseStoredDraft(existing);
const nextTags =
mode === 'replace' ? normalizeDraftTagNames(tagNames) : normalizeDraftTagNames([...draft.tags, ...tagNames]);
return updateCourseImportDraftService(orgId, draftId, {
draft: {
...draft,
tags: nextTags
}
});
}
export async function publishCourseImportDraftService( export async function publishCourseImportDraftService(
orgId: string, orgId: string,
profileId: string, profileId: string,
draftId: string, draftId: string,
overrides: TCourseImportDraftPublish overrides: TCourseImportDraftPublish
) { ): Promise<PublishDraftResult> {
try { try {
const existing = await getCourseImportDraftService(orgId, draftId); const existing = await getCourseImportDraftService(orgId, draftId);
if (existing.status === 'PUBLISHED' && existing.publishedCourseId) { if (existing.status === 'PUBLISHED' && existing.publishedCourseId) {
const [publishedCourse] = await getCourseById(existing.publishedCourseId);
const courseUrl = await resolveCourseUrl(
orgId,
existing.publishedCourseId,
publishedCourse?.title ?? existing.title
);
const draft = parseStoredDraft(existing);
return { return {
courseId: existing.publishedCourseId, courseId: existing.publishedCourseId,
courseUrl,
bannerImageUrl: publishedCourse?.logo ?? null,
tagNames: normalizeDraftTagNames(draft.tags),
createdSections: 0, createdSections: 0,
createdLessons: 0, createdLessons: 0,
localeCount: 0 localeCount: 0
@@ -182,6 +601,9 @@ export async function publishCourseImportDraftService(
await updateCourse(courseId, { metadata: courseMetadata }); await updateCourse(courseId, { metadata: courseMetadata });
} }
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
await maybeApplyCourseBannerImage(courseId, bannerImageUrl);
const sectionIdMap = new Map<string, string>(); const sectionIdMap = new Map<string, string>();
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) { for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
const createdSection = await createCourseSection(courseId, { const createdSection = await createCourseSection(courseId, {
@@ -203,6 +625,7 @@ export async function publishCourseImportDraftService(
const createdLesson = await createLesson(courseId, { const createdLesson = await createLesson(courseId, {
courseId, courseId,
title: lesson.title, title: lesson.title,
note: getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, draft.course.locale as TLocale),
sectionId, sectionId,
order: lesson.order, order: lesson.order,
isUnlocked: lesson.isUnlocked, isUnlocked: lesson.isUnlocked,
@@ -224,6 +647,16 @@ export async function publishCourseImportDraftService(
}); });
} }
const appliedTagNames = normalizeDraftTagNames(draft.tags);
if (appliedTagNames.length > 0) {
await assignCourseTagsByName(orgId, courseId, {
tagNames: appliedTagNames,
mode: 'replace'
});
}
const courseUrl = await resolveCourseUrl(orgId, courseId, courseTitle);
await updateCourseImportDraft(orgId, draftId, { await updateCourseImportDraft(orgId, draftId, {
status: 'PUBLISHED', status: 'PUBLISHED',
publishedCourseId: courseId, publishedCourseId: courseId,
@@ -236,6 +669,9 @@ export async function publishCourseImportDraftService(
return { return {
courseId, courseId,
courseUrl,
bannerImageUrl,
tagNames: appliedTagNames,
createdSections: sectionIdMap.size, createdSections: sectionIdMap.size,
createdLessons: lessonIdMap.size, createdLessons: lessonIdMap.size,
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
@@ -252,3 +688,184 @@ export async function publishCourseImportDraftService(
); );
} }
} }
export async function publishCourseImportDraftToExistingCourseService(
orgId: string,
draftId: string,
overrides: TCourseImportDraftPublishToCourse
): Promise<PublishDraftToExistingCourseResult> {
try {
const existingDraft = await getCourseImportDraftService(orgId, draftId);
const draft = parseStoredDraft(existingDraft);
const course = await getCourseRecord(orgId, overrides.courseId);
const courseTitle = overrides.title ?? draft.course.title;
const courseDescription = overrides.description ?? draft.course.description;
const courseType = overrides.type ?? draft.course.type;
const courseMetadata = overrides.metadata ?? draft.course.metadata;
const preferredLocale = draft.course.locale as TLocale;
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
await updateCourse(course.id, {
title: courseTitle,
description: courseDescription,
type: courseType,
metadata: courseMetadata
});
await maybeApplyCourseBannerImage(course.id, bannerImageUrl);
const [existingSections, existingLessons] = await Promise.all([
listCourseSections(course.id),
listLessons(course.id)
]);
const existingSectionIds = new Set(existingSections.map((section) => section.id));
const existingLessonIds = new Set(existingLessons.map((lesson) => lesson.id));
const existingLessonLanguages = await getLessonLanguagesByLessonIds(existingLessons.map((lesson) => lesson.id));
const sectionIdMap = new Map<string, string>();
let createdSections = 0;
let updatedSections = 0;
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
if (existingSectionIds.has(section.externalId)) {
const updatedSection = await updateCourseSectionService(section.externalId, {
title: section.title,
order: section.order
});
sectionIdMap.set(section.externalId, updatedSection.id);
updatedSections += 1;
continue;
}
const createdSection = await createCourseSection(course.id, {
courseId: course.id,
title: section.title,
order: section.order
});
sectionIdMap.set(section.externalId, createdSection.id);
createdSections += 1;
}
const lessonIdMap = new Map<string, string>();
let createdLessons = 0;
let updatedLessons = 0;
for (const lesson of [...draft.lessons].sort((a, b) => a.order - b.order)) {
const sectionId = sectionIdMap.get(lesson.sectionExternalId);
if (!sectionId) {
throw new AppError(`Lesson "${lesson.title}" references an unknown section`, ErrorCodes.VALIDATION_ERROR, 400);
}
const note = getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, preferredLocale);
if (existingLessonIds.has(lesson.externalId)) {
const updatedLesson = await updateLessonService(lesson.externalId, {
title: lesson.title,
note,
sectionId,
order: lesson.order,
isUnlocked: lesson.isUnlocked,
public: lesson.public
});
lessonIdMap.set(lesson.externalId, updatedLesson.id);
updatedLessons += 1;
continue;
}
const createdLesson = await createLesson(course.id, {
courseId: course.id,
title: lesson.title,
note,
sectionId,
order: lesson.order,
isUnlocked: lesson.isUnlocked,
public: lesson.public
});
lessonIdMap.set(lesson.externalId, createdLesson.id);
createdLessons += 1;
}
const existingLessonLanguageMap = new Map(
existingLessonLanguages.map((lessonLanguage) => [
`${lessonLanguage.lessonId}:${lessonLanguage.locale}`,
lessonLanguage
])
);
let createdLessonLanguages = 0;
let updatedLessonLanguages = 0;
for (const lessonLanguage of draft.lessonLanguages) {
const lessonId = lessonIdMap.get(lessonLanguage.lessonExternalId);
if (!lessonId) {
throw new AppError('Referenced lesson was not created', ErrorCodes.VALIDATION_ERROR, 400);
}
const languageKey = `${lessonId}:${lessonLanguage.locale}`;
if (existingLessonLanguageMap.has(languageKey)) {
await updateLessonLanguageService(lessonId, lessonLanguage.locale as TLocale, {
content: lessonLanguage.content
});
updatedLessonLanguages += 1;
continue;
}
await upsertLessonLanguageService(lessonId, {
locale: lessonLanguage.locale as TLocale,
content: lessonLanguage.content
});
createdLessonLanguages += 1;
}
const appliedTagNames = normalizeDraftTagNames(draft.tags);
if (appliedTagNames.length > 0) {
await assignCourseTagsByName(orgId, course.id, {
tagNames: appliedTagNames,
mode: 'merge'
});
}
const referencedSectionIds = new Set([...sectionIdMap.values()].filter((id) => existingSectionIds.has(id)));
const referencedLessonIds = new Set([...lessonIdMap.values()].filter((id) => existingLessonIds.has(id)));
const courseUrl = await resolveCourseUrl(orgId, course.id, courseTitle);
await updateCourseImportDraft(orgId, draftId, {
status: 'PUBLISHED',
publishedCourseId: course.id,
summary: {
...(existingDraft.summary ?? {}),
...buildDraftSummary(draft),
publishedCourseId: course.id,
updatedSections,
updatedLessons,
createdLessonLanguages,
updatedLessonLanguages
}
});
return {
courseId: course.id,
courseUrl,
bannerImageUrl,
tagNames: appliedTagNames,
createdSections,
updatedSections,
createdLessons,
updatedLessons,
createdLessonLanguages,
updatedLessonLanguages,
untouchedSections: existingSections.length - referencedSectionIds.size,
untouchedLessons: existingLessons.length - referencedLessonIds.size,
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to publish course import draft to existing course',
ErrorCodes.COURSE_IMPORT_PUBLISH_FAILED,
500
);
}
}
+112 -9
View File
@@ -1,13 +1,21 @@
import { AppError, ErrorCodes } from '@api/utils/errors'; import { AppError, ErrorCodes } from '@api/utils/errors';
import { ContentType } from '@cio/utils/constants'; import { ContentType } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle'; import { db, type DbOrTxClient } from '@cio/db/drizzle';
import { sql, inArray } from 'drizzle-orm'; import { sql, inArray } from 'drizzle-orm';
import { lesson, exercise } from '@cio/db/schema'; import { courseSection, lesson, exercise } from '@cio/db/schema';
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content'; import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
import { deleteLesson } from '@cio/db/queries/lesson'; import { deleteLesson } from '@cio/db/queries/lesson';
import { deleteCourseSection, getCourseSectionById } from '@cio/db/queries/course'; import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
import { deleteExercise } from '@cio/db/queries/exercise/exercise'; import { deleteExercise } from '@cio/db/queries/exercise/exercise';
import type { TCourseContentDelete, TCourseContentUpdate } from '@cio/utils/validation/course'; import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
type CourseContentReorderResult = {
courseId: string;
updatedSections: number;
updatedItems: number;
updatedLessons: number;
updatedExercises: number;
};
function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) { function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
@@ -64,11 +72,18 @@ async function assertCourseContentItems(courseId: string, items: TCourseContentU
}); });
} }
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) { async function assertCourseSections(courseId: string, sections: NonNullable<TCourseContentReorder['sections']>) {
try { const existingSections = await getCourseSectionsByCourseId(courseId);
await assertCourseContentItems(courseId, items); const sectionIds = new Set(existingSections.map((section) => section.id));
await db.transaction(async (tx) => { sections.forEach((section) => {
if (!sectionIds.has(section.id)) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
});
}
async function applyCourseContentUpdates(tx: DbOrTxClient, items: TCourseContentUpdate['items']) {
const updates = items const updates = items
.map((item) => ({ .map((item) => ({
item, item,
@@ -77,7 +92,11 @@ export async function updateCourseContent(courseId: string, items: TCourseConten
.filter(({ payload }) => Object.keys(payload).length > 0); .filter(({ payload }) => Object.keys(payload).length > 0);
if (updates.length === 0) { if (updates.length === 0) {
return; return {
updatedItems: 0,
updatedLessons: 0,
updatedExercises: 0
};
} }
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
@@ -180,6 +199,48 @@ export async function updateCourseContent(courseId: string, items: TCourseConten
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404); throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
} }
} }
return {
updatedItems: updates.length,
updatedLessons: lessonUpdates.length,
updatedExercises: exerciseUpdates.length
};
}
async function applySectionOrderUpdates(tx: DbOrTxClient, sections: NonNullable<TCourseContentReorder['sections']>) {
if (sections.length === 0) {
return 0;
}
const sectionIds = sections.map((section) => section.id);
const updatedAt = new Date().toISOString();
const sectionSet: Record<string, unknown> = {
updatedAt,
order: sql`case ${courseSection.id} ${sql.join(
sections.map((section) => sql`when ${section.id} then ${section.order}`),
sql` `
)} else ${courseSection.order} end`
};
const updatedSections = await tx
.update(courseSection)
.set(sectionSet)
.where(inArray(courseSection.id, sectionIds))
.returning({ id: courseSection.id });
if (updatedSections.length !== sectionIds.length) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
return sections.length;
}
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) {
try {
await assertCourseContentItems(courseId, items);
await db.transaction(async (tx) => {
await applyCourseContentUpdates(tx, items);
}); });
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
@@ -194,6 +255,48 @@ export async function updateCourseContent(courseId: string, items: TCourseConten
} }
} }
export async function reorderCourseContent(
courseId: string,
payload: TCourseContentReorder
): Promise<CourseContentReorderResult> {
try {
if (payload.sections?.length) {
await assertCourseSections(courseId, payload.sections);
}
if (payload.items?.length) {
await assertCourseContentItems(courseId, payload.items);
}
return await db.transaction(async (tx) => {
const updatedSections = payload.sections?.length ? await applySectionOrderUpdates(tx, payload.sections) : 0;
const itemResult = payload.items?.length
? await applyCourseContentUpdates(tx, payload.items)
: {
updatedItems: 0,
updatedLessons: 0,
updatedExercises: 0
};
return {
courseId,
updatedSections,
...itemResult
};
});
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to reorder course content',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteCourseContent(courseId: string, payload: TCourseContentDelete) { export async function deleteCourseContent(courseId: string, payload: TCourseContentDelete) {
try { try {
if (payload.sectionId) { if (payload.sectionId) {
@@ -0,0 +1,224 @@
import * as schema from '@cio/db/schema';
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TCourse, TOrganization } from '@db/types';
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
import { db } from '@cio/db/drizzle';
import { env } from '@api/config/env';
import { eq } from 'drizzle-orm';
import { generateSlug } from '@cio/utils/functions';
import { getCourseById } from '@cio/db/queries/course';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getOrganizationById } from '@cio/db/queries/organization';
import { updateCourse } from '@api/services/course/course';
type CourseLandingPageUpdateResult = {
course: TCourse;
courseUrl: string;
bannerImageUrl: string | null;
};
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
let suffix = 0;
let candidate = baseSlug;
while (true) {
const existing = await db
.select({ id: schema.course.id })
.from(schema.course)
.where(eq(schema.course.slug, candidate))
.limit(1);
if (existing.length === 0) {
return candidate;
}
suffix += 1;
candidate = `${baseSlug}-${suffix}`;
}
}
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
const [course] = await getCourseById(courseId);
if (course?.slug) {
return course.slug;
}
const baseSlug = generateSlug(title, { fallback: 'course' });
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
await updateCourse(courseId, { slug: uniqueSlug });
return uniqueSlug;
}
function buildCourseBaseUrl(organization: TOrganization) {
if (organization.customDomain && organization.isCustomDomainVerified) {
return `https://${organization.customDomain}`;
}
return getDashboardBaseUrl(organization.siteName ?? undefined);
}
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
const organization = await getOrganizationById(organizationId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
}
const slug = await ensureCourseSlug(courseId, title);
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
if (!env.UNSPLASH_API_KEY) {
return null;
}
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
const response = await fetch(
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
{ method: 'GET' }
);
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
results?: Array<{
urls?: {
regular?: string;
};
}>;
};
const photos = data.results ?? [];
if (photos.length === 0) {
return null;
}
const photo = photos[Math.floor(Math.random() * photos.length)];
return photo?.urls?.regular ?? null;
}
async function resolveLandingPageImage(
courseTitle: string,
payload: Pick<TCourseLandingPageUpdate, 'generateImage' | 'imageQuery' | 'imageUrl'>
) {
if (payload.imageUrl) {
return payload.imageUrl;
}
if (!payload.generateImage && !payload.imageQuery) {
return null;
}
try {
return await resolveUnsplashBannerImage(courseTitle, payload.imageQuery);
} catch (error) {
console.error('resolveLandingPageImage error:', error);
return null;
}
}
function mergeLandingPageMetadata(
existing: TCourse['metadata'] | null | undefined,
update: TCourseLandingPageMetadataUpdate | undefined
): TCourse['metadata'] | undefined {
if (!update) {
return existing ?? undefined;
}
const current: NonNullable<TCourse['metadata']> = {
goals: '',
description: '',
requirements: '',
allowNewStudent: true,
...(existing ?? {})
};
return {
...current,
...update,
reward: update.reward
? {
show: update.reward.show ?? current.reward?.show ?? false,
description: update.reward.description ?? current.reward?.description ?? ''
}
: current.reward,
instructor: update.instructor
? {
name: update.instructor.name ?? current.instructor?.name ?? '',
role: update.instructor.role ?? current.instructor?.role ?? '',
coursesNo: update.instructor.coursesNo ?? current.instructor?.coursesNo ?? 0,
description: update.instructor.description ?? current.instructor?.description ?? '',
imgUrl: update.instructor.imgUrl ?? current.instructor?.imgUrl ?? ''
}
: current.instructor,
certificate: update.certificate
? {
templateUrl: update.certificate.templateUrl ?? current.certificate?.templateUrl ?? ''
}
: current.certificate,
sectionDisplay: update.sectionDisplay
? { ...(current.sectionDisplay ?? {}), ...update.sectionDisplay }
: current.sectionDisplay,
reviews: update.reviews ?? current.reviews,
lessonTabsOrder: update.lessonTabsOrder ?? current.lessonTabsOrder
};
}
function normalizeReviews(reviews: NonNullable<TCourse['metadata']>['reviews'] | undefined) {
return reviews?.map((review, index) => ({
id: review.id ?? Date.now() + index,
hide: review.hide ?? false,
name: review.name,
avatar_url: review.avatar_url,
rating: review.rating,
created_at: review.created_at ?? Date.now(),
description: review.description
}));
}
export async function updateCourseLandingPageService(
courseId: string,
payload: TCourseLandingPageUpdate
): Promise<CourseLandingPageUpdateResult> {
const [existingCourse] = await getCourseById(courseId);
if (!existingCourse) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const nextTitle = payload.title ?? existingCourse.title;
const metadata = mergeLandingPageMetadata(existingCourse.metadata ?? undefined, payload.metadata);
const imageUrl = await resolveLandingPageImage(nextTitle, payload);
const updatedCourse = await updateCourse(courseId, {
title: payload.title,
description: payload.description,
overview: payload.overview,
cost: payload.cost,
currency: payload.currency,
metadata: metadata
? {
...metadata,
reviews: normalizeReviews(metadata.reviews)
}
: undefined,
...(imageUrl ? { logo: imageUrl, bannerImage: imageUrl } : {})
});
const organizationId = await getCourseOrganizationId(courseId);
if (!organizationId) {
throw new AppError('Course organization not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const courseUrl = await resolveCourseUrl(organizationId, courseId, updatedCourse.title);
return {
course: updatedCourse,
courseUrl,
bannerImageUrl: updatedCourse.logo || imageUrl || null
};
}
@@ -15,13 +15,19 @@ import {
listOrganizationApiKeys, listOrganizationApiKeys,
updateOrganizationApiKey updateOrganizationApiKey
} from '@cio/db/queries/organization'; } from '@cio/db/queries/organization';
import { assertOrganizationAutomationKeyCreationAllowed } from './automation-usage';
const DEFAULT_SCOPES: Record<TOrganizationApiKeyType, TOrganizationApiKeyScope[]> = { const DEFAULT_SCOPES: Record<TOrganizationApiKeyType, TOrganizationApiKeyScope[]> = {
mcp: [ mcp: [
'course_import:draft:create', 'course_import:draft:create',
'course_import:draft:read', 'course_import:draft:read',
'course_import:draft:update', 'course_import:draft:update',
'course_import:draft:publish' 'course_import:draft:publish',
'course:read',
'course:write',
'course:tag:write',
'course:exercise:read',
'course:exercise:write'
], ],
api: ['public_api:*'], api: ['public_api:*'],
zapier: ['public_api:*'] zapier: ['public_api:*']
@@ -86,6 +92,8 @@ export async function createOrganizationApiKeyService(
createdByProfileId: string, createdByProfileId: string,
payload: TCreateOrganizationApiKey payload: TCreateOrganizationApiKey
): Promise<{ key: OrganizationApiKeySummary; secret: string }> { ): Promise<{ key: OrganizationApiKeySummary; secret: string }> {
await assertOrganizationAutomationKeyCreationAllowed(organizationId, payload.type);
const secret = generateOrganizationApiKeySecret(payload.type); const secret = generateOrganizationApiKeySecret(payload.type);
const key = await createOrganizationApiKey({ const key = await createOrganizationApiKey({
organizationId, organizationId,
@@ -0,0 +1,188 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
countActiveOrganizationApiKeys,
countOrganizationAutomationUsageSince,
countOrganizationAutomationUsageSinceByKey,
createOrganizationAutomationUsage,
getActiveOrganizationPlan,
getOrganizationAutomationCreditsUsedSince,
listRecentOrganizationAutomationUsage
} from '@cio/db/queries/organization';
import type { TOrganizationApiKey, TOrganizationApiKeyType, TPlan } from '@db/types';
import {
AUTOMATION_TYPE,
getMcpAutomationCategory,
getMcpAutomationLimits,
MCP_TOOL_CREDIT_COST,
type TMcpToolName
} from '@cio/utils/plans';
export type OrganizationAutomationUsageSummary = {
type: TOrganizationApiKeyType;
planName: TPlan;
billingPeriodStart: string;
billingPeriodEnd: string;
monthlyCreditsIncluded: number;
monthlyCreditsUsed: number;
monthlyCreditsRemaining: number;
activeKeys: number;
maxActiveKeys: number;
rateLimits: ReturnType<typeof getMcpAutomationLimits>['rateLimits'];
recentActions: Array<{
action: string;
creditsConsumed: number;
createdAt: string;
}>;
};
function getBillingPeriodStart(date: Date = new Date()) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
}
function getBillingPeriodEnd(date: Date = new Date()) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0));
}
function getMinuteWindowStart(date: Date = new Date()) {
return new Date(date.getTime() - 60_000);
}
async function getOrganizationPlanName(orgId: string): Promise<TPlan> {
const activePlan = await getActiveOrganizationPlan(orgId);
return (activePlan?.planName as TPlan | null) ?? 'BASIC';
}
export async function assertOrganizationAutomationKeyCreationAllowed(
organizationId: string,
type: TOrganizationApiKeyType
): Promise<void> {
if (type !== AUTOMATION_TYPE.MCP) {
return;
}
const planName = await getOrganizationPlanName(organizationId);
const limits = getMcpAutomationLimits(planName);
const activeKeys = await countActiveOrganizationApiKeys(organizationId, type);
if (activeKeys >= limits.maxActiveKeys) {
throw new AppError(
`Your plan allows up to ${limits.maxActiveKeys} active MCP key${limits.maxActiveKeys === 1 ? '' : 's'}`,
ErrorCodes.AUTOMATION_KEY_LIMIT_EXCEEDED,
403
);
}
}
export async function getOrganizationAutomationUsageSummaryService(
organizationId: string,
type: TOrganizationApiKeyType
): Promise<OrganizationAutomationUsageSummary> {
const planName = await getOrganizationPlanName(organizationId);
const billingPeriodStart = getBillingPeriodStart();
const billingPeriodEnd = getBillingPeriodEnd();
if (type !== AUTOMATION_TYPE.MCP) {
return {
type,
planName,
billingPeriodStart: billingPeriodStart.toISOString(),
billingPeriodEnd: billingPeriodEnd.toISOString(),
monthlyCreditsIncluded: 0,
monthlyCreditsUsed: 0,
monthlyCreditsRemaining: 0,
activeKeys: await countActiveOrganizationApiKeys(organizationId, type),
maxActiveKeys: 0,
rateLimits: {
perKey: { readPerMinute: 0, writePerMinute: 0, publishPerMinute: 0 },
perOrg: { readPerMinute: 0, writePerMinute: 0, publishPerMinute: 0 }
},
recentActions: []
};
}
const limits = getMcpAutomationLimits(planName);
const [monthlyCreditsUsed, activeKeys, recentActions] = await Promise.all([
getOrganizationAutomationCreditsUsedSince(organizationId, type, billingPeriodStart.toISOString()),
countActiveOrganizationApiKeys(organizationId, type),
listRecentOrganizationAutomationUsage(organizationId, type, 10)
]);
return {
type,
planName,
billingPeriodStart: billingPeriodStart.toISOString(),
billingPeriodEnd: billingPeriodEnd.toISOString(),
monthlyCreditsIncluded: limits.monthlyCredits,
monthlyCreditsUsed,
monthlyCreditsRemaining: Math.max(limits.monthlyCredits - monthlyCreditsUsed, 0),
activeKeys,
maxActiveKeys: limits.maxActiveKeys,
rateLimits: limits.rateLimits,
recentActions: recentActions.map((action) => ({
action: action.action,
creditsConsumed: action.creditsConsumed,
createdAt: action.createdAt
}))
};
}
export async function assertMcpAutomationUsageAllowed(
automationKey: TOrganizationApiKey,
toolName: TMcpToolName
): Promise<void> {
const planName = await getOrganizationPlanName(automationKey.organizationId);
const limits = getMcpAutomationLimits(planName);
const category = getMcpAutomationCategory(toolName);
const now = new Date();
const billingPeriodStart = getBillingPeriodStart(now).toISOString();
const minuteWindowStart = getMinuteWindowStart(now).toISOString();
const [creditsUsed, keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([
getOrganizationAutomationCreditsUsedSince(automationKey.organizationId, automationKey.type, billingPeriodStart),
countOrganizationAutomationUsageSinceByKey(automationKey.id, category, minuteWindowStart),
countOrganizationAutomationUsageSince(automationKey.organizationId, automationKey.type, category, minuteWindowStart)
]);
const creditsNeeded = MCP_TOOL_CREDIT_COST[toolName];
if (creditsUsed + creditsNeeded > limits.monthlyCredits) {
throw new AppError(
'Monthly automation credits exhausted for this organization',
ErrorCodes.AUTOMATION_CREDIT_LIMIT_EXCEEDED,
429
);
}
const keyLimit =
category === 'read'
? limits.rateLimits.perKey.readPerMinute
: category === 'write'
? limits.rateLimits.perKey.writePerMinute
: limits.rateLimits.perKey.publishPerMinute;
const orgLimit =
category === 'read'
? limits.rateLimits.perOrg.readPerMinute
: category === 'write'
? limits.rateLimits.perOrg.writePerMinute
: limits.rateLimits.perOrg.publishPerMinute;
if (keyRequestsInWindow >= keyLimit || orgRequestsInWindow >= orgLimit) {
throw new AppError('Automation rate limit exceeded', ErrorCodes.AUTOMATION_RATE_LIMIT_EXCEEDED, 429);
}
}
export async function recordMcpAutomationUsage(
automationKey: TOrganizationApiKey,
toolName: TMcpToolName,
metadata: Record<string, unknown> = {}
): Promise<void> {
await createOrganizationAutomationUsage({
organizationId: automationKey.organizationId,
organizationApiKeyId: automationKey.id,
type: automationKey.type,
action: toolName,
category: getMcpAutomationCategory(toolName),
creditsConsumed: MCP_TOOL_CREDIT_COST[toolName],
metadata
});
}
+71
View File
@@ -1,4 +1,6 @@
import type { import type {
TAutomationCourseTagAssignment,
TAutomationDraftTagAssignment,
TCourseTagAssignment, TCourseTagAssignment,
TTagCreate, TTagCreate,
TTagGroupCreate, TTagGroupCreate,
@@ -8,6 +10,8 @@ import type {
import { import {
createTag, createTag,
createTagGroup, createTagGroup,
findTagBySlug,
findTagGroupBySlug,
generateUniqueTagGroupSlug, generateUniqueTagGroupSlug,
generateUniqueTagSlug, generateUniqueTagSlug,
getCourseOrganizationId, getCourseOrganizationId,
@@ -21,6 +25,8 @@ import {
updateTagGroup updateTagGroup
} from '@cio/db/queries/tag'; } from '@cio/db/queries/tag';
import { getOrgIdBySiteName, isUserOrgAdmin } from '@cio/db/queries/organization'; import { getOrgIdBySiteName, isUserOrgAdmin } from '@cio/db/queries/organization';
import { TAG_COLOR_OPTIONS, type TTagColor } from '@cio/utils/validation/tag';
import { slugifyTagValue } from '@cio/db/queries/tag';
import { AppError, ErrorCodes } from '@api/utils/errors'; import { AppError, ErrorCodes } from '@api/utils/errors';
@@ -34,6 +40,51 @@ function normalizeOptionalText(value: string | undefined): string | null | undef
return normalized.length > 0 ? normalized : null; return normalized.length > 0 ? normalized : null;
} }
function normalizeAutomationTagNames(tagNames: string[]) {
return Array.from(new Map(tagNames.map((name) => [name.trim().toLowerCase(), name.trim()])).values()).filter(Boolean);
}
function pickTagColor(name: string): TTagColor {
const seed = Array.from(name).reduce((acc, char) => acc + char.charCodeAt(0), 0);
return TAG_COLOR_OPTIONS[seed % TAG_COLOR_OPTIONS.length] as TTagColor;
}
async function ensureAutomationTagGroup(orgId: string, groupName: string) {
const slug = slugifyTagValue(groupName);
const existing = await findTagGroupBySlug(orgId, slug);
if (existing) {
return existing;
}
return createOrganizationTagGroup(orgId, { name: groupName });
}
async function ensureAutomationTags(orgId: string, tagNames: string[], groupName: string) {
const normalizedTagNames = normalizeAutomationTagNames(tagNames);
const group = await ensureAutomationTagGroup(orgId, groupName);
const tags = [];
for (const tagName of normalizedTagNames) {
const slug = slugifyTagValue(tagName);
const existing = await findTagBySlug(orgId, slug);
if (existing) {
tags.push(existing);
continue;
}
const created = await createOrganizationTag(orgId, {
groupId: group.id,
name: tagName,
color: pickTagColor(tagName)
});
tags.push(created);
}
return tags;
}
export async function getOrganizationTagGroups(orgId: string) { export async function getOrganizationTagGroups(orgId: string) {
try { try {
return getTagGroupsWithTags(orgId); return getTagGroupsWithTags(orgId);
@@ -301,3 +352,23 @@ export async function replaceCourseTags(
); );
} }
} }
export async function assignCourseTagsByName(orgId: string, courseId: string, data: TAutomationDraftTagAssignment) {
const ensuredTags = await ensureAutomationTags(orgId, data.tagNames, data.groupName?.trim() || 'Automation');
const existingTags = data.mode === 'merge' ? await getCourseTags(orgId, courseId) : [];
const tagIds = Array.from(new Set([...existingTags.map((tag) => tag.id), ...ensuredTags.map((tag) => tag.id)]));
return replaceCourseTags(orgId, courseId, { tagIds });
}
export async function assignTagsToCoursesByName(orgId: string, data: TAutomationCourseTagAssignment) {
const normalizedCourseIds = Array.from(new Set(data.courseIds));
const results = [];
for (const courseId of normalizedCourseIds) {
const tags = await assignCourseTagsByName(orgId, courseId, data);
results.push({ courseId, tags });
}
return results;
}
+3
View File
@@ -67,6 +67,9 @@ export const ErrorCodes = {
ORGANIZATION_API_KEY_FETCH_FAILED: 'ORGANIZATION_API_KEY_FETCH_FAILED', ORGANIZATION_API_KEY_FETCH_FAILED: 'ORGANIZATION_API_KEY_FETCH_FAILED',
ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED', ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED',
ORGANIZATION_API_KEY_ROTATE_FAILED: 'ORGANIZATION_API_KEY_ROTATE_FAILED', ORGANIZATION_API_KEY_ROTATE_FAILED: 'ORGANIZATION_API_KEY_ROTATE_FAILED',
AUTOMATION_KEY_LIMIT_EXCEEDED: 'AUTOMATION_KEY_LIMIT_EXCEEDED',
AUTOMATION_CREDIT_LIMIT_EXCEEDED: 'AUTOMATION_CREDIT_LIMIT_EXCEEDED',
AUTOMATION_RATE_LIMIT_EXCEEDED: 'AUTOMATION_RATE_LIMIT_EXCEEDED',
TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND', TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND',
TAG_NOT_FOUND: 'TAG_NOT_FOUND', TAG_NOT_FOUND: 'TAG_NOT_FOUND',
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED', TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
+8 -8
View File
@@ -18,14 +18,14 @@
"types": ["node"], "types": ["node"],
"paths": { "paths": {
"@api/*": ["src/*"], "@api/*": ["src/*"],
"@db/types": ["./node_modules/@cio/db/dist/types.d.ts"], "@db/types": ["../../packages/db/src/types.ts"],
"@db/*": ["./node_modules/@cio/db/dist/*"], "@db/*": ["../../packages/db/src/*"],
"@cio/db/*": ["./node_modules/@cio/db/dist/*"], "@cio/db/*": ["../../packages/db/src/*"],
"@cio/db/types": ["./node_modules/@cio/db/dist/types.d.ts"], "@cio/db/types": ["../../packages/db/src/types.ts"],
"@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"], "@cio/utils": ["../../packages/utils/src/index.ts"],
"@cio/utils/*": ["./node_modules/@cio/utils/dist/*"], "@cio/utils/*": ["../../packages/utils/src/*"],
"@cio/email/*": ["./node_modules/@cio/email/dist/*"], "@cio/email/*": ["../../packages/email/src/*"],
"@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"] "@cio/email": ["../../packages/email/src/index.ts"]
} }
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
+11 -4
View File
@@ -6,12 +6,19 @@ PUBLIC_IS_SELFHOSTED=false
# PUBLIC_APP_DESCRIPTION=A flexible LMS for your organization # PUBLIC_APP_DESCRIPTION=A flexible LMS for your organization
# PUBLIC_OG_IMAGE_URL=https://app.yourdomain.com/og-image.png # PUBLIC_OG_IMAGE_URL=https://app.yourdomain.com/og-image.png
# CSP: External domains for script-src, style-src, connect-src, frame-src, font-src. # CSP: External domains allowed by Content-Security-Policy.
# When PUBLIC_IS_SELFHOSTED=true: REQUIRED - no defaults, only these env vars are used. # These are runtime env vars — set them when running the container (no rebuild needed).
# When PUBLIC_IS_SELFHOSTED=false: Optional - unset uses SaaS defaults. # When PUBLIC_IS_SELFHOSTED=true: starts with 'self' only; add your domains here.
# When PUBLIC_IS_SELFHOSTED=false: unset uses SaaS defaults.
# Comma-separated, e.g. ALLOWED_EXTERNAL_DOMAINS=assets.cdn.clsrio.com,cdnjs.cloudflare.com,cdn.plyr.io # Comma-separated, e.g. ALLOWED_EXTERNAL_DOMAINS=assets.cdn.clsrio.com,cdnjs.cloudflare.com,cdn.plyr.io
# Per-directive: CSP_SCRIPT_SRC_DOMAINS, CSP_STYLE_SRC_DOMAINS, CSP_CONNECT_SRC_DOMAINS, CSP_FRAME_SRC_DOMAINS, CSP_FONT_SRC_DOMAINS # ALLOWED_EXTERNAL_DOMAINS overrides all per-directive vars below.
# ALLOWED_EXTERNAL_DOMAINS= # ALLOWED_EXTERNAL_DOMAINS=
# CSP_SCRIPT_SRC_DOMAINS=
# CSP_STYLE_SRC_DOMAINS=
# CSP_CONNECT_SRC_DOMAINS=
# CSP_FRAME_SRC_DOMAINS=
# CSP_FONT_SRC_DOMAINS=
# CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src)
# Unsplash setup - Optional if you want to use Unsplash images # Unsplash setup - Optional if you want to use Unsplash images
UNSPLASH_API_KEY= UNSPLASH_API_KEY=
+99 -14
View File
@@ -46,22 +46,111 @@ html .dark {
border: 2px solid var(--scrollbarBG); border: 2px solid var(--scrollbarBG);
} }
.prose {
color: var(--foreground);
}
.prose h1, .prose h1,
.prose h2, .prose h2,
.prose h3, .prose h3,
.prose h4, .prose h4 {
.prose h5, @apply scroll-m-20 tracking-tight text-balance;
.prose h6 { color: var(--foreground);
font-size: revert !important; }
font-weight: revert !important;
margin-top: 0px !important; .prose h1 {
margin-bottom: 16px !important; @apply mt-0 text-4xl font-extrabold;
line-height: 1.25 !important; }
.prose h2 {
@apply mt-10 border-b pb-2 text-3xl font-semibold transition-colors;
}
.prose h2:first-child {
@apply mt-0;
}
.prose h3 {
@apply mt-8 text-2xl font-semibold;
}
.prose h4 {
@apply mt-8 text-xl font-semibold;
}
.prose p,
.prose li,
.prose blockquote {
@apply leading-7;
} }
.prose p { .prose p {
margin-top: 0px !important; @apply my-0;
margin-bottom: 10px !important; }
.prose p:not(:first-child) {
@apply mt-6;
}
.prose a {
@apply font-medium underline underline-offset-4;
color: var(--primary);
}
.prose blockquote {
@apply mt-6 border-s-2 ps-6 italic;
border-inline-start-color: var(--border);
}
.prose ul,
.prose ol {
@apply my-6 ms-6 p-0;
}
.prose ul {
@apply list-disc;
}
.prose ol {
@apply list-decimal;
}
.prose li {
@apply mt-2;
}
.prose table {
@apply my-6 w-full border-collapse;
}
.prose thead tr,
.prose tbody tr {
@apply m-0 border-t p-0;
border-top-color: var(--border);
}
.prose tbody tr:nth-child(even) {
background: var(--muted);
}
.prose th,
.prose td {
@apply border px-4 py-2 text-start;
border-color: var(--border);
}
.prose th {
@apply font-bold;
}
.prose th[align='center'],
.prose td[align='center'] {
@apply text-center;
}
.prose th[align='right'],
.prose td[align='right'] {
@apply text-end;
} }
/* .dark .prose h1, /* .dark .prose h1,
@@ -106,10 +195,6 @@ iframe.iframe {
} }
} }
.prose a {
color: var(--main-primary-color);
}
:not([data-slot='button']) > svg.lucide-icon:not(.custom) { :not([data-slot='button']) > svg.lucide-icon:not(.custom) {
color: #666666; color: #666666;
} }
+7 -4
View File
@@ -1,5 +1,6 @@
import { getSessionData } from '$lib/utils/services/auth/session'; import { getSessionData } from '$lib/utils/services/auth/session';
import { getCioCookieString } from '$lib/utils/functions/cookies'; import { getCioCookieString } from '$lib/utils/functions/cookies';
import { applyCspExtensions } from '$lib/utils/csp';
import { type Handle, redirect } from '@sveltejs/kit'; import { type Handle, redirect } from '@sveltejs/kit';
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute'; import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
import { ROUTE } from '$lib/utils/constants/routes'; import { ROUTE } from '$lib/utils/constants/routes';
@@ -12,13 +13,15 @@ export const handle: Handle = async (args) => {
event.locals = sessionData; event.locals = sessionData;
} }
// Handle API routes let response: Response;
if (event.url.pathname.includes('/api')) { if (event.url.pathname.includes('/api')) {
return handleAPIRoutes(args); response = await handleAPIRoutes(args);
} else {
response = await handlePagesRoutes(args);
} }
// Handle other routes (ui pages, etc.) return applyCspExtensions(response);
return handlePagesRoutes(args);
}; };
const handlePagesRoutes: Handle = async ({ event, resolve }) => { const handlePagesRoutes: Handle = async ({ event, resolve }) => {
@@ -0,0 +1,44 @@
/**
* Global SSO auth state for the current org. Shared by login and signup
* so we don't re-fetch when navigating between them.
*/
import { get, writable } from 'svelte/store';
import { INITIAL_SSO_AUTH_STATE, ssoStateFromOrgInfo, type OrgSsoInfoShape, type SsoAuthState } from './auth-sso';
export type AuthSsoStore = {
/** Org we loaded SSO info for; when nav changes org we re-fetch */
loadedForOrgId: string | null;
/** Org-level SSO state from getOrgSsoInfo */
ssoState: SsoAuthState;
orgSupportsSso: boolean;
};
const initial: AuthSsoStore = {
loadedForOrgId: null,
ssoState: INITIAL_SSO_AUTH_STATE,
orgSupportsSso: false
};
export const authSsoStore = writable<AuthSsoStore>(initial);
/**
* Ensures SSO info for the given org is loaded. If we already have data for
* this orgId, does nothing. Otherwise fetches and updates the store.
*/
export async function ensureSsoInfoLoaded(
orgId: string,
fetchOrgSsoInfo: (id: string) => Promise<OrgSsoInfoShape>
): Promise<void> {
const current = get(authSsoStore);
if (current.loadedForOrgId === orgId) return;
const orgInfo = await fetchOrgSsoInfo(orgId);
const ssoState = ssoStateFromOrgInfo(orgInfo);
authSsoStore.set({
loadedForOrgId: orgId,
ssoState,
orgSupportsSso: orgInfo?.hasSso ?? false
});
}
@@ -0,0 +1,101 @@
/**
* Shared SSO auth state and helpers for login/signup flows.
* Used by (auth)/login and (auth)/signup to avoid duplicating SSO logic.
*/
export type SsoAuthState = {
checking: boolean;
required: boolean;
available: boolean;
redirectUrl: string | null;
providerName: string | null;
providerId: string | null;
};
export const INITIAL_SSO_AUTH_STATE: SsoAuthState = {
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
};
/** Shape returned by getOrgSsoInfo (avoids importing org types here). */
export type OrgSsoInfoShape = {
hasSso: boolean;
ssoRequired?: boolean;
redirectUrl?: string | null;
providerName?: string | null;
providerId?: string | null;
} | null;
/** Shape returned by discoverSso (avoids importing org types here). */
export type SsoDiscoveryResultShape = {
ssoRequired?: boolean;
ssoAvailable?: boolean;
redirectUrl?: string | null;
providerName?: string | null;
providerId?: string | null;
} | null;
export function ssoStateFromOrgInfo(orgInfo: OrgSsoInfoShape): SsoAuthState {
if (!orgInfo) return INITIAL_SSO_AUTH_STATE;
return {
checking: false,
required: orgInfo.ssoRequired ?? false,
available: orgInfo.hasSso ?? false,
redirectUrl: orgInfo.redirectUrl ?? null,
providerName: orgInfo.providerName ?? null,
providerId: orgInfo.providerId ?? null
};
}
export function ssoStateFromDiscovery(result: SsoDiscoveryResultShape): SsoAuthState {
if (!result) return INITIAL_SSO_AUTH_STATE;
return {
checking: false,
required: result.ssoRequired ?? false,
available: result.ssoAvailable ?? false,
redirectUrl: result.redirectUrl ?? null,
providerName: result.providerName ?? null,
providerId: result.providerId ?? null
};
}
export function buildSsoRedirectUrl(redirectParam: string | null): string {
const base = typeof window !== 'undefined' ? window.location.origin : '';
const path = redirectParam && redirectParam.startsWith('/') ? redirectParam : '';
return `${base}${path}`;
}
const SSO_EMAIL_DEBOUNCE_MS = 500;
export function createSsoEmailChecker(options: {
getEmail: () => string;
getOrgSupportsSso: () => boolean;
discoverSso: (email: string) => Promise<SsoDiscoveryResultShape>;
onChecking: () => void;
onResult: (state: SsoAuthState) => void;
onClear: () => void;
}) {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return function handleEmailChange() {
options.onClear();
if (!options.getOrgSupportsSso()) return;
clearTimeout(timeoutId);
const email = options.getEmail();
if (!email || !email.includes('@')) {
options.onResult(INITIAL_SSO_AUTH_STATE);
return;
}
options.onChecking();
timeoutId = setTimeout(async () => {
const result = await options.discoverSso(email);
options.onResult(ssoStateFromDiscovery(result));
}, SSO_EMAIL_DEBOUNCE_MS);
};
}
@@ -1,7 +1,9 @@
import type { import type {
AutomationKey, AutomationKey,
AutomationKeyType, AutomationKeyType,
AutomationUsage,
CreateAutomationKeyRequest, CreateAutomationKeyRequest,
GetAutomationUsageRequest,
ListAutomationKeysRequest, ListAutomationKeysRequest,
RevokeAutomationKeyRequest, RevokeAutomationKeyRequest,
RotateAutomationKeyRequest RotateAutomationKeyRequest
@@ -15,6 +17,7 @@ class AutomationApi extends BaseApiWithErrors {
generatedSecret = $state<string | null>(null); generatedSecret = $state<string | null>(null);
isFetched = $state(false); isFetched = $state(false);
keys = $state<AutomationKey[]>([]); keys = $state<AutomationKey[]>([]);
usage = $state<AutomationUsage | null>(null);
async listKeys(type?: AutomationKeyType) { async listKeys(type?: AutomationKeyType) {
return this.execute<ListAutomationKeysRequest>({ return this.execute<ListAutomationKeysRequest>({
@@ -30,6 +33,19 @@ class AutomationApi extends BaseApiWithErrors {
}); });
} }
async getUsage(type: AutomationKeyType = 'mcp') {
return this.execute<GetAutomationUsageRequest>({
requestFn: () =>
classroomio.organization.automation.usage.$get({
query: { type }
}),
logContext: 'fetching automation usage',
onSuccess: (response) => {
this.usage = response.data;
}
});
}
async createKey(type: AutomationKeyType, label?: string) { async createKey(type: AutomationKeyType, label?: string) {
return this.execute<CreateAutomationKeyRequest>({ return this.execute<CreateAutomationKeyRequest>({
requestFn: () => requestFn: () =>
@@ -43,6 +59,9 @@ class AutomationApi extends BaseApiWithErrors {
onSuccess: (response) => { onSuccess: (response) => {
this.generatedSecret = response.data.secret; this.generatedSecret = response.data.secret;
this.keys = [response.data.key, ...this.keys]; this.keys = [response.data.key, ...this.keys];
if (this.usage) {
this.usage = { ...this.usage, activeKeys: this.usage.activeKeys + 1 };
}
snackbar.success('snackbar.automation.created'); snackbar.success('snackbar.automation.created');
} }
}); });
@@ -54,6 +73,10 @@ class AutomationApi extends BaseApiWithErrors {
logContext: 'revoking automation key', logContext: 'revoking automation key',
onSuccess: (response) => { onSuccess: (response) => {
this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key)); this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key));
if (this.usage) {
const activeKeys = this.keys.filter((key) => key.type === 'mcp' && !key.revokedAt).length;
this.usage = { ...this.usage, activeKeys };
}
snackbar.success('snackbar.automation.revoked'); snackbar.success('snackbar.automation.revoked');
} }
}); });
@@ -0,0 +1,14 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import * as Field from '@cio/ui/base/field';
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
<Alert.Callout
variant="information"
title={$t('automation.tabs.api')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Field.Group>
@@ -1,269 +0,0 @@
<script lang="ts">
import { automationApi } from '$features/automation/api/automation.svelte';
import {
getClaudeCodeSnippet,
getCodexSnippet,
getCursorSnippet,
getMaskedAutomationSecret
} from '$features/automation/utils/automation-utils';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import * as Card from '@cio/ui/base/card';
import * as Code from '@cio/ui/custom/code';
import * as Field from '@cio/ui/base/field';
import * as Tabs from '@cio/ui/base/tabs';
import KeyIcon from '@lucide/svelte/icons/key';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
import BracesIcon from '@lucide/svelte/icons/braces';
let activeTab = $state('mcp');
let activeSetupTab = $state('claude-code');
let isRevokingKeyId = $state<string | null>(null);
let isRotatingKeyId = $state<string | null>(null);
$effect(() => {
if (!$isOrgAdmin || automationApi.isFetched) return;
automationApi.listKeys('mcp');
});
async function onGenerateKey() {
await automationApi.createKey('mcp');
}
async function onRevokeKey(keyId: string) {
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
isRevokingKeyId = keyId;
await automationApi.revokeKey(keyId);
isRevokingKeyId = null;
}
async function onRotateKey(keyId: string) {
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
isRotatingKeyId = keyId;
await automationApi.rotateKey(keyId);
isRotatingKeyId = null;
}
const mcpSecret = $derived(automationApi.generatedSecret);
const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp'));
const claudeCodeSnippet = $derived(getClaudeCodeSnippet(mcpSecret));
const codexSnippet = $derived(getCodexSnippet(mcpSecret));
const cursorSnippet = $derived(getCursorSnippet(mcpSecret));
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
{#if !$isOrgAdmin}
<Alert.Callout
variant="information"
title={$t('automation.permissions.disabled_title')}
description={$t('automation.permissions.disabled_description')}
class="w-full"
/>
{/if}
<Tabs.Root bind:value={activeTab} class="w-full">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="mcp">{$t('automation.tabs.mcp')}</Tabs.Trigger>
<Tabs.Trigger value="zapier" disabled>
<span>{$t('automation.tabs.zapier')}</span>
<Badge variant="outline" class="text-[10px]!">{$t('automation.coming_soon')}</Badge>
</Tabs.Trigger>
<Tabs.Trigger value="api" disabled>
<span>{$t('automation.tabs.api')}</span>
<Badge variant="outline" class="text-[10px]!">{$t('automation.coming_soon')}</Badge>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcp" class="space-y-8">
<Field.Set>
<Field.Legend class="flex items-center gap-2">
<KeyIcon class="size-5" />
{$t('automation.mcp.keys.title')}
</Field.Legend>
<Field.Description>{$t('automation.mcp.keys.description')}</Field.Description>
<div class="flex flex-wrap items-center gap-3">
<Button
onclick={onGenerateKey}
variant="secondary"
loading={automationApi.isLoading}
disabled={!$isOrgAdmin || automationApi.isLoading}
>
{$t('automation.mcp.keys.generate')}
</Button>
</div>
{#if automationApi.generatedSecret}
<div class="mt-2 rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="ui:text-amber-800 text-sm font-medium">{$t('automation.mcp.keys.secret_once')}</p>
<div class="mt-3">
<Code.Root
code={automationApi.generatedSecret}
lang="bash"
hideLines={true}
class="ui:break-all ui:text-xs"
>
<Code.CopyButton />
</Code.Root>
</div>
</div>
{/if}
{#if $isOrgAdmin}
{#if mcpKeys.length > 0}
<div class="mt-6 grid gap-4 md:grid-cols-2">
{#each mcpKeys as key (key.id)}
<Card.Root>
<Card.Header>
<div class="flex items-start justify-between gap-3">
<div>
<Card.Title>{key.label}</Card.Title>
<Card.Description>{getMaskedAutomationSecret(key.secretPrefix)}</Card.Description>
</div>
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
</Badge>
</div>
</Card.Header>
<Card.Content class="space-y-2 text-sm">
<p>
<span class="font-medium">{$t('automation.keys.created_at_label')}</span>
{new Date(key.createdAt).toLocaleString()}
</p>
<p>
<span class="font-medium">{$t('automation.keys.last_used_label')}</span>
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : $t('automation.keys.never_used')}
</p>
<p>
<span class="font-medium">{$t('automation.keys.scopes_label')}</span>
{key.scopes.join(', ')}
</p>
</Card.Content>
<Card.Footer class="flex gap-2">
<Button
variant="secondary"
onclick={() => onRotateKey(key.id)}
loading={isRotatingKeyId === key.id}
disabled={!$isOrgAdmin || Boolean(key.revokedAt)}
>
{$t('automation.keys.rotate')}
</Button>
<Button
variant="destructive"
onclick={() => onRevokeKey(key.id)}
loading={isRevokingKeyId === key.id}
disabled={!$isOrgAdmin || Boolean(key.revokedAt)}
>
{$t('automation.keys.revoke')}
</Button>
</Card.Footer>
</Card.Root>
{/each}
</div>
{:else}
<Alert.Callout
variant="information"
title={$t('automation.keys.empty_title')}
description={$t('automation.keys.empty_description')}
class="mt-6 w-full"
/>
{/if}
{/if}
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend class="flex items-center gap-2">
<SparklesIcon class="size-5" />
{$t('automation.mcp.setup.title')}
</Field.Legend>
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
<Tabs.Root bind:value={activeSetupTab} class="w-full">
<Tabs.List class="inline-flex w-auto">
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="claude-code" class="mt-4">
<Card.Root>
<Card.Header>
<Card.Title>{$t('automation.clients.claude_code')}</Card.Title>
<Card.Description>{$t('automation.mcp.setup.claude_code_description')}</Card.Description>
</Card.Header>
<Card.Content>
<Code.FileName filename="terminal" />
<Code.Overflow>
<Code.Root code={claudeCodeSnippet} lang="bash" class="ui:w-full ui:border-none">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="codex" class="mt-4">
<Card.Root>
<Card.Header>
<Card.Title>{$t('automation.clients.codex')}</Card.Title>
<Card.Description>{$t('automation.mcp.setup.codex_description')}</Card.Description>
</Card.Header>
<Card.Content>
<Code.FileName filename="terminal" />
<Code.Overflow>
<Code.Root code={codexSnippet} lang="bash" class="ui:w-full ui:border-none">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="cursor" class="mt-4">
<Card.Root>
<Card.Header>
<Card.Title>{$t('automation.clients.cursor')}</Card.Title>
<Card.Description>{$t('automation.mcp.setup.cursor_description')}</Card.Description>
</Card.Header>
<Card.Content>
<Code.FileName filename="mcp.json" />
<Code.Overflow>
<Code.Root code={cursorSnippet} lang="json" class="ui:w-full ui:border-none">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</Field.Set>
</Tabs.Content>
<Tabs.Content value="zapier" class="mt-6">
<Alert.Callout
variant="information"
title={$t('automation.tabs.zapier')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Tabs.Content>
<Tabs.Content value="api" class="mt-6">
<Alert.Callout
variant="information"
title={$t('automation.tabs.api')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Tabs.Content>
</Tabs.Root>
</Field.Group>
@@ -1 +1,3 @@
export { default as AutomationPage } from './automation.svelte'; export { default as ApiAutomationPage } from './api.svelte';
export { default as McpAutomationPage } from './mcp.svelte';
export { default as ZapierAutomationPage } from './zapier.svelte';
@@ -0,0 +1,239 @@
<script lang="ts">
import { automationApi } from '$features/automation/api/automation.svelte';
import {
getClaudeCodeSnippet,
getCodexSnippet,
getCursorSnippet,
getMaskedAutomationSecret
} from '$features/automation/utils/automation-utils';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import { Badge } from '@cio/ui/base/badge';
import * as Code from '@cio/ui/custom/code';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Field from '@cio/ui/base/field';
import * as Table from '@cio/ui/base/table';
import * as Tabs from '@cio/ui/base/tabs';
import { IconButton } from '@cio/ui/custom/icon-button';
import CoinsIcon from '@lucide/svelte/icons/coins';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import GaugeIcon from '@lucide/svelte/icons/gauge';
import KeyIcon from '@lucide/svelte/icons/key';
import PlusIcon from '@lucide/svelte/icons/plus';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { ActivityCard } from '$features/ui';
let activeSetupTab = $state('cursor');
let generatedSecret = $state<string | null>(null);
async function onGenerateKey() {
await automationApi.createKey('mcp');
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
}
async function onRevokeKey(keyId: string) {
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
await automationApi.revokeKey(keyId);
}
async function onRotateKey(keyId: string) {
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
await automationApi.rotateKey(keyId);
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
}
const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp'));
const usageCards = $derived.by(() => {
if (!automationApi.usage) return [];
return [
{
icon: CoinsIcon,
title: t.get('automation.mcp.usage.credits_title'),
percentage: automationApi.usage.monthlyCreditsRemaining,
description: `${t.get('automation.mcp.usage.used_label')} ${automationApi.usage.monthlyCreditsUsed} / ${automationApi.usage.monthlyCreditsIncluded}`,
hidePercentage: true
},
{
icon: KeyIcon,
title: t.get('automation.mcp.usage.keys_title'),
percentage: automationApi.usage.activeKeys,
description: `${t.get('automation.mcp.usage.used_label')} ${automationApi.usage.activeKeys} / ${automationApi.usage.maxActiveKeys}`,
hidePercentage: true
},
{
icon: GaugeIcon,
title: t.get('automation.mcp.usage.rate_limits_title'),
percentage: `${automationApi.usage.rateLimits.perKey.readPerMinute} / ${automationApi.usage.rateLimits.perKey.writePerMinute} / ${automationApi.usage.rateLimits.perKey.publishPerMinute}`,
description: `${t.get('automation.mcp.usage.rate_limits_description')}`,
hidePercentage: true
}
];
});
const isLastUsageCard = (index: number) => index === usageCards.length - 1;
</script>
<Field.Group class="mx-auto w-full space-y-2">
{#if !$isOrgAdmin}
<Alert.Callout
variant="information"
title={$t('automation.permissions.disabled_title')}
description={$t('automation.permissions.disabled_description')}
class="w-full"
/>
{/if}
{#if $isOrgAdmin && automationApi.usage}
<div class="grid gap-4 md:grid-cols-3">
{#each usageCards as card, i (i)}
<ActivityCard
activity={card}
classes={{
percentage: isLastUsageCard(i) ? 'text-lg' : undefined
}}
/>
{/each}
</div>
{/if}
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-medium">
<KeyIcon class="size-5" />
{$t('automation.mcp.keys.title')}
</div>
{#if $isOrgAdmin}
<IconButton
onclick={onGenerateKey}
disabled={automationApi.isLoading}
tooltip={$t('automation.mcp.keys.generate')}
>
<PlusIcon size={16} />
</IconButton>
{/if}
</div>
<p class="ui:text-muted-foreground text-sm">{$t('automation.mcp.keys.description')}</p>
{#if generatedSecret}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="ui:text-amber-800 text-sm font-medium">{$t('automation.mcp.keys.secret_once')}</p>
<div class="mt-3">
<Code.Root code={generatedSecret} lang="bash" hideLines={true} class="ui:break-all ui:text-xs">
<Code.CopyButton />
</Code.Root>
</div>
</div>
{/if}
{#if $isOrgAdmin}
{#if mcpKeys.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('automation.keys.table_key')}</Table.Head>
<Table.Head>{$t('automation.keys.table_status')}</Table.Head>
<Table.Head>{$t('automation.keys.table_created')}</Table.Head>
<Table.Head>{$t('automation.keys.table_last_used')}</Table.Head>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each mcpKeys as key (key.id)}
<Table.Row>
<Table.Cell>
<div>
<p class="font-medium">{key.label}</p>
<p class="ui:text-muted-foreground text-xs">
{getMaskedAutomationSecret(key.secretPrefix)}
</p>
</div>
</Table.Cell>
<Table.Cell>
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
</Badge>
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">
{new Date(key.createdAt).toLocaleDateString()}
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : $t('automation.keys.never_used')}
</Table.Cell>
<Table.Cell>
{#if !key.revokedAt}
<DropdownMenu.Root>
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
<IconButton aria-label="actions">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => onRotateKey(key.id)}>
{$t('automation.keys.rotate')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => onRevokeKey(key.id)} class="ui:text-destructive">
{$t('automation.keys.revoke')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<Alert.Callout
variant="information"
title={$t('automation.keys.empty_title')}
description={$t('automation.keys.empty_description')}
class="w-full"
/>
{/if}
{/if}
</div>
<Field.Set class="gap-3!">
<Field.Legend class="flex items-center gap-2">
<SparklesIcon class="size-5" />
{$t('automation.mcp.setup.title')}
</Field.Legend>
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
<Tabs.Root bind:value={activeSetupTab} class="w-full">
<Tabs.List class="inline-flex w-auto">
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="claude-code" class="mt-4">
<Code.Overflow>
<Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="codex" class="mt-4">
<Code.Overflow>
<Code.Root code={getCodexSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="cursor" class="mt-4">
<Code.Overflow>
<Code.Root code={getCursorSnippet(generatedSecret)} lang="json">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
</Tabs.Root>
</Field.Set>
</Field.Group>
@@ -0,0 +1,14 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import * as Field from '@cio/ui/base/field';
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
<Alert.Callout
variant="information"
title={$t('automation.tabs.zapier')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Field.Group>
@@ -1,15 +1,18 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api'; import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type ListAutomationKeysRequest = (typeof classroomio.organization)['automation']['keys']['$get']; export type ListAutomationKeysRequest = (typeof classroomio.organization)['automation']['keys']['$get'];
export type GetAutomationUsageRequest = (typeof classroomio.organization)['automation']['usage']['$get'];
export type CreateAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys']['$post']; export type CreateAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys']['$post'];
export type RevokeAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys'][':keyId']['$delete']; export type RevokeAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys'][':keyId']['$delete'];
export type RotateAutomationKeyRequest = export type RotateAutomationKeyRequest =
(typeof classroomio.organization)['automation']['keys'][':keyId']['rotate']['$post']; (typeof classroomio.organization)['automation']['keys'][':keyId']['rotate']['$post'];
export type ListAutomationKeysSuccess = Extract<InferResponseType<ListAutomationKeysRequest>, { success: true }>; export type ListAutomationKeysSuccess = Extract<InferResponseType<ListAutomationKeysRequest>, { success: true }>;
export type GetAutomationUsageSuccess = Extract<InferResponseType<GetAutomationUsageRequest>, { success: true }>;
export type CreateAutomationKeySuccess = Extract<InferResponseType<CreateAutomationKeyRequest>, { success: true }>; export type CreateAutomationKeySuccess = Extract<InferResponseType<CreateAutomationKeyRequest>, { success: true }>;
export type RevokeAutomationKeySuccess = Extract<InferResponseType<RevokeAutomationKeyRequest>, { success: true }>; export type RevokeAutomationKeySuccess = Extract<InferResponseType<RevokeAutomationKeyRequest>, { success: true }>;
export type RotateAutomationKeySuccess = Extract<InferResponseType<RotateAutomationKeyRequest>, { success: true }>; export type RotateAutomationKeySuccess = Extract<InferResponseType<RotateAutomationKeyRequest>, { success: true }>;
export type AutomationKey = ListAutomationKeysSuccess['data'][number]; export type AutomationKey = ListAutomationKeysSuccess['data'][number];
export type AutomationKeyType = AutomationKey['type']; export type AutomationKeyType = AutomationKey['type'];
export type AutomationUsage = GetAutomationUsageSuccess['data'];
@@ -6,7 +6,7 @@
import { shortenName } from '$lib/utils/functions/string'; import { shortenName } from '$lib/utils/functions/string';
import { Vote } from '$features/ui'; import { Vote } from '$features/ui';
import { CircleCheckIcon } from '$features/ui/icons'; import { CircleCheckIcon } from '$features/ui/icons';
import { sanitizeHtml } from '@cio/ui/tools/sanitize'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface CommentAuthor { interface CommentAuthor {
id: number | string | null; id: number | string | null;
@@ -65,7 +65,7 @@
{/if} {/if}
</header> </header>
<article class="prose prose-sm sm:prose p-2"> <article class="prose prose-sm sm:prose p-2">
{@html sanitizeHtml(comment.body ?? '')} <SafeHtmlContent content={comment.body ?? ''} />
</article> </article>
</div> </div>
</div> </div>
@@ -22,7 +22,7 @@
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components'; import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
import type { CommunityQuestionSuccess } from '../utils/types'; import type { CommunityQuestionSuccess } from '../utils/types';
import { currentCommunityQuestion } from '../utils/store'; import { currentCommunityQuestion } from '../utils/store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface Props { interface Props {
slug: string; slug: string;
@@ -211,7 +211,7 @@
</div> </div>
{:else} {:else}
<section class="prose prose-sm sm:prose p-2"> <section class="prose prose-sm sm:prose p-2">
{@html sanitizeHtml(communityApi.question?.body ?? '')} <SafeHtmlContent content={communityApi.question?.body ?? ''} />
</section> </section>
{/if} {/if}
</div> </div>
@@ -20,6 +20,7 @@
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter'; import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions'; import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
import CardDropdown from './card-dropdown.svelte'; import CardDropdown from './card-dropdown.svelte';
import CoursePublishBadge from './course-publish-badge.svelte';
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types'; import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
import type { OrgPublicCourses } from '$features/org/utils/types'; import type { OrgPublicCourses } from '$features/org/utils/types';
@@ -196,13 +197,7 @@
</div> </div>
{/if} {/if}
{:else} {:else}
<Badge variant={isPublished ? 'default' : 'outline'}> <CoursePublishBadge {isPublished} />
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
{/if} {/if}
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { COURSE_SORT_OPTIONS } from '../utils/constants'; import { COURSE_SORT_OPTIONS } from '../utils/constants';
import * as Popover from '@cio/ui/base/popover'; import * as Popover from '@cio/ui/base/popover';
import * as ToggleGroup from '@cio/ui/base/toggle-group';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner'; import { Spinner } from '@cio/ui/base/spinner';
import { Checkbox } from '@cio/ui/base/checkbox'; import { Checkbox } from '@cio/ui/base/checkbox';
@@ -27,6 +28,7 @@
interface Props { interface Props {
selectedId?: string; selectedId?: string;
selectedOrder?: 'asc' | 'desc';
selectedTags?: string[]; selectedTags?: string[];
tagGroups?: FilterTagGroup[]; tagGroups?: FilterTagGroup[];
sortOptions?: readonly SortOption[]; sortOptions?: readonly SortOption[];
@@ -37,6 +39,7 @@
let { let {
selectedId = $bindable('0'), selectedId = $bindable('0'),
selectedOrder = $bindable('desc' as 'asc' | 'desc'),
selectedTags = [], selectedTags = [],
tagGroups = [], tagGroups = [],
sortOptions = COURSE_SORT_OPTIONS, sortOptions = COURSE_SORT_OPTIONS,
@@ -47,8 +50,7 @@
let isOpen = $state(false); let isOpen = $state(false);
const hasActiveFilters = $derived(selectedId !== '0' || selectedTags.length > 0); const hasActiveFilters = $derived(selectedId !== '0' || selectedOrder !== 'desc' || selectedTags.length > 0);
const hasActiveTagFilters = $derived(selectedTags.length > 0);
function isTagSelected(tagSlug: string) { function isTagSelected(tagSlug: string) {
return selectedTags.includes(tagSlug); return selectedTags.includes(tagSlug);
@@ -66,7 +68,7 @@
<FilterIcon size={16} /> <FilterIcon size={16} />
{/if} {/if}
</IconButton> </IconButton>
{#if hasActiveTagFilters} {#if hasActiveFilters}
<span <span
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white" class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
aria-hidden="true" aria-hidden="true"
@@ -103,6 +105,26 @@
</div> </div>
</div> </div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.order')}
</p>
<ToggleGroup.Root
type="single"
variant="outline"
size="sm"
value={selectedOrder}
onValueChange={(value) => {
if (value === 'asc' || value === 'desc') {
selectedOrder = value;
}
}}
>
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
<div class="space-y-3"> <div class="space-y-3">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p> <p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
{#if tagGroups.length === 0} {#if tagGroups.length === 0}
@@ -3,21 +3,35 @@
import * as Sidebar from '@cio/ui/base/sidebar'; import * as Sidebar from '@cio/ui/base/sidebar';
import BellIcon from '@lucide/svelte/icons/bell'; import BellIcon from '@lucide/svelte/icons/bell';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Popover from '@cio/ui/base/popover'; import * as Popover from '@cio/ui/base/popover';
import Search from '$features/ui/search.svelte'; import { HoverableItem, PreviewIcon } from '@cio/ui/custom/moving-icons';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw'; import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty'; import * as Empty from '@cio/ui/base/empty';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte'; import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { openCoursePreview } from '$features/course/utils/course-preview';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
const siteName = $derived($currentOrg.siteName); const siteName = $derived($currentOrg.siteName);
const showCoursePublishBadge = $derived(!$isStudentExperience);
$effect(() => { $effect(() => {
if (!siteName) return; if (!siteName) return;
setupProgressApi.fetchSetupProgress(siteName); setupProgressApi.fetchSetupProgress(siteName);
}); });
function handlePreview() {
openCoursePreview({
courseId: courseApi.course?.id ?? '',
courseSlug: courseApi.course?.slug,
currentOrgDomain: $currentOrgDomain
});
}
</script> </script>
<header <header
@@ -30,13 +44,17 @@
<Separator orientation="vertical" /> <Separator orientation="vertical" />
</div> </div>
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-2xs truncate text-sm font-medium"> <p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''} {courseApi.course?.title || ''}
</p> </p>
<span class="grow"></span> {#if showCoursePublishBadge}
<CoursePublishBadge isPublished={courseApi.course?.isPublished} />
{/if}
</div>
<Search /> <span class="grow"></span>
<Popover.Root> <Popover.Root>
<Popover.Trigger> <Popover.Trigger>
@@ -56,11 +74,24 @@
<Empty.Content> <Empty.Content>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<RefreshCcwIcon /> <RefreshCcwIcon />
Refresh {$t('common.refresh')}
</Button> </Button>
</Empty.Content> </Empty.Content>
</Empty.Root> </Empty.Root>
</Popover.Content> </Popover.Content>
</Popover.Root> </Popover.Root>
<HoverableItem>
{#snippet children(isHovered)}
<IconButton
onclick={handlePreview}
disabled={!courseApi.course?.id}
tooltip={$t('course.header.preview')}
aria-label={$t('course.header.preview')}
>
<PreviewIcon {isHovered} size={16} />
</IconButton>
{/snippet}
</HoverableItem>
</div> </div>
</header> </header>
@@ -0,0 +1,18 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
interface Props {
isPublished?: boolean;
}
let { isPublished = false }: Props = $props();
</script>
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { questionnaire } from './store'; import { questionnaire } from './store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { InputField } from '@cio/ui/custom/input-field'; import { InputField } from '@cio/ui/custom/input-field';
@@ -49,9 +49,9 @@
</div> </div>
<article class="preview prose prose-sm sm:prose mt-3 p-2"> <article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml( <SafeHtmlContent
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no') content={$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')}
)} />
</article> </article>
{:else} {:else}
<InputField <InputField
@@ -21,7 +21,7 @@
import { toApiPayload, type AnswerData } from '@cio/question-types'; import { toApiPayload, type AnswerData } from '@cio/question-types';
import { exerciseApi, presignApi } from '$features/course/api'; import { exerciseApi, presignApi } from '$features/course/api';
import type { SubmissionListItem } from '$features/course/utils/types'; import type { SubmissionListItem } from '$features/course/utils/types';
import { sanitizeHtml } from '@cio/ui/tools/sanitize'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { ContentType } from '@cio/utils/constants/content'; import { ContentType } from '@cio/utils/constants/content';
@@ -422,9 +422,10 @@
</div> </div>
<article class="preview prose prose-sm sm:prose mt-3 p-2"> <article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml( <SafeHtmlContent
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description') content={$questionnaire.description ||
)} $t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')}
/>
</article> </article>
<Button onclick={handleStart} type="button" class="float-right my-5"> <Button onclick={handleStart} type="button" class="float-right my-5">
@@ -8,7 +8,7 @@
import { lessonApi } from '$features/course/api'; import { lessonApi } from '$features/course/api';
import { diffLines } from 'diff'; import { diffLines } from 'diff';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { sanitizeHtml } from '@cio/ui/tools/sanitize'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
@@ -83,7 +83,7 @@
); );
} }
function fetchLessonHistory(lessonId: string, locale: string, endRange: number) { function fetchLessonHistory(lessonId: string, locale: TLocale, endRange: number) {
untrack(async () => { untrack(async () => {
try { try {
isMoreHistoryLoading = true; isMoreHistoryLoading = true;
@@ -100,9 +100,18 @@
const data = response.data; const data = response.data;
// Filter out duplicates based on timestamp const toLessonHistory = (item: (typeof data)[number]): LessonHistory => ({
old_content: item.oldContent ?? '',
new_content: item.newContent ?? '',
timestamp: item.timestamp ? new Date(item.timestamp) : new Date(),
locale: (item.locale as TLocale) ?? 'en',
lesson_id: item.lessonId ?? ''
});
const existingTimestamps = new Set(lessonHistory.map((item) => new Date(item.timestamp).getMinutes())); const existingTimestamps = new Set(lessonHistory.map((item) => new Date(item.timestamp).getMinutes()));
const newEntries = data.filter((item) => !existingTimestamps.has(new Date(item.timestamp!).getMinutes())); const newEntries = data
.filter((item) => !existingTimestamps.has(new Date(item.timestamp ?? 0).getMinutes()))
.map(toLessonHistory);
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]); lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
if (lessonHistory.length > 0) { if (lessonHistory.length > 0) {
@@ -206,7 +215,7 @@
{#key lessonId} {#key lessonId}
<HTMLRender id="display" className="m-auto"> <HTMLRender id="display" className="m-auto">
<div class="amen"> <div class="amen">
{@html sanitizeHtml(content)} <SafeHtmlContent {content} />
</div> </div>
</HTMLRender> </HTMLRender>
{/key} {/key}
@@ -222,7 +231,7 @@
</p> </p>
<div> <div>
{#each lessonHistory as version, index} {#each lessonHistory as version, index (index)}
<button <button
onclick={() => updateContentVersion(version, index)} onclick={() => updateContentVersion(version, index)}
class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index == class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index ==
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { HTMLRender, TextEditor } from '$features/ui'; import { HTMLRender, TextEditor } from '$features/ui';
import { sanitizeHtml } from '@cio/ui/tools/sanitize'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml'; import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { lessonApi } from '$features/course/api'; import { lessonApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
@@ -43,10 +43,6 @@
} }
const content = $derived(lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || ''); const content = $derived(lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '');
$effect(() => {
console.log('content', content);
});
</script> </script>
{#if mode === MODES.edit} {#if mode === MODES.edit}
@@ -67,7 +63,7 @@
<!-- View Mode --> <!-- View Mode -->
{#if !isHtmlValueEmpty(content)} {#if !isHtmlValueEmpty(content)}
<HTMLRender> <HTMLRender>
{@html sanitizeHtml(content)} <SafeHtmlContent {content} />
</HTMLRender> </HTMLRender>
{:else if hasAtLeastOneTranslation} {:else if hasAtLeastOneTranslation}
<p class="text-md py-2 font-normal italic dark:text-white"> <p class="text-md py-2 font-normal italic dark:text-white">
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { Badge } from '@cio/ui/base/badge';
import * as Table from '@cio/ui/base/table'; import * as Table from '@cio/ui/base/table';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu'; import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte'; import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
interface Props { interface Props {
id?: string; id?: string;
@@ -88,13 +88,7 @@
<Table.Cell>{totalLessons}</Table.Cell> <Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell> <Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell> <Table.Cell>
<Badge variant={isPublished ? 'default' : 'outline'}> <CoursePublishBadge {isPublished} />
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
</Table.Cell> </Table.Cell>
{/if} {/if}
<Table.Cell class="text-center"> <Table.Cell class="text-center">
@@ -4,7 +4,7 @@
import { calDateDiff } from '$lib/utils/functions/date'; import { calDateDiff } from '$lib/utils/functions/date';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml'; import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { sanitizeHtml } from '@cio/ui/tools/sanitize'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { Feed } from '$features/course/utils/types'; import type { Feed } from '$features/course/utils/types';
@@ -59,7 +59,7 @@
{#if !isHtmlValueEmpty(feed.content || '')} {#if !isHtmlValueEmpty(feed.content || '')}
<HTMLRender className="w-[80%]"> <HTMLRender className="w-[80%]">
<div> <div>
{@html sanitizeHtml(feed.content || '')} <SafeHtmlContent content={feed.content || ''} />
</div> </div>
</HTMLRender> </HTMLRender>
{/if} {/if}
@@ -35,7 +35,7 @@
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from '$features/course/components/exercise/update-description.svelte'; import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
import { ContentNavigationActions } from '$features/course/components/lesson'; import { ContentNavigationActions } from '$features/course/components/lesson';
import { RoleBasedSecurity } from '$features/ui'; import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { getOrderedNavigableContent } from '$features/course/utils/content'; import { getOrderedNavigableContent } from '$features/course/utils/content';
import { Empty } from '@cio/ui/custom/empty'; import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video'; import VideoIcon from '@lucide/svelte/icons/video';
@@ -266,6 +266,8 @@
</div> </div>
</div> </div>
{/if} {/if}
<RefreshPageData />
</RoleBasedSecurity> </RoleBasedSecurity>
</div> </div>
</Page.Action> </Page.Action>
@@ -18,6 +18,7 @@
import { globalStore } from '$lib/utils/store/app'; import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { RefreshPageData } from '$features/ui';
import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte'; import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
import { courseApi, lessonApi } from '$features/course/api'; import { courseApi, lessonApi } from '$features/course/api';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml'; import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
@@ -260,6 +261,7 @@
if (!lessonApi.lesson || !lessonId) return; if (!lessonApi.lesson || !lessonId) return;
if (!lessonApi.isDirty) return; if (!lessonApi.isDirty) return;
if ($isLoading) return; if ($isLoading) return;
console.log('autoSave');
autoSave(); autoSave();
}); });
@@ -268,6 +270,7 @@
let didHandleExitEdit = false; let didHandleExitEdit = false;
$effect(() => { $effect(() => {
if (!lessonId) return; if (!lessonId) return;
console.log('didHandleExitEdit');
const currentParam = $page.url.searchParams.get('mode'); const currentParam = $page.url.searchParams.get('mode');
const prev = prevModeParam; const prev = prevModeParam;
@@ -345,8 +348,10 @@
</IconButton> </IconButton>
</div> </div>
<LanguageSelector /> <RefreshPageData onRefresh={() => lessonApi.get(courseId, lessonId)} />
</RoleBasedSecurity> </RoleBasedSecurity>
<LanguageSelector />
</div> </div>
</Page.Action> </Page.Action>
</Page.Header> </Page.Header>
@@ -0,0 +1,27 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
interface OpenCoursePreviewOptions {
courseId: string;
courseSlug?: string | null;
currentOrgDomain?: string;
}
export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = '' }: OpenCoursePreviewOptions) {
if (!courseSlug) {
snackbar.info('course.header.preview_missing_slug');
if (courseId) {
goto(resolve(`/courses/${courseId}/settings`, {}));
}
return false;
}
const origin = currentOrgDomain || window.location.origin;
const link = new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
window.open(link, '_blank', 'noopener,noreferrer');
return true;
}
@@ -8,10 +8,12 @@
import * as Select from '@cio/ui/base/select'; import * as Select from '@cio/ui/base/select';
import * as Table from '@cio/ui/base/table'; import * as Table from '@cio/ui/base/table';
import { Empty } from '@cio/ui/custom/empty'; import { Empty } from '@cio/ui/custom/empty';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field'; import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field'; import { TextareaField } from '@cio/ui/custom/textarea-field';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import TagIcon from '@lucide/svelte/icons/tag'; import TagIcon from '@lucide/svelte/icons/tag';
import { tagApi } from '$features/tag/api'; import { tagApi } from '$features/tag/api';
@@ -222,7 +224,7 @@
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each tagApi.tagGroups as group (group.id)} {#each tagApi.tagGroups as group (group.id)}
<Table.Row class="ui:bg-muted/20"> <Table.Row class="group ui:bg-muted/20">
<Table.Cell colspan={5}> <Table.Cell colspan={5}>
<div class="flex items-start justify-between gap-4 py-1"> <div class="flex items-start justify-between gap-4 py-1">
<div class="space-y-1"> <div class="space-y-1">
@@ -231,9 +233,22 @@
<p class="ui:text-muted-foreground max-w-[320px] truncate text-xs">{group.description}</p> <p class="ui:text-muted-foreground max-w-[320px] truncate text-xs">{group.description}</p>
{/if} {/if}
</div> </div>
<Button variant="ghost-default" size="sm" onclick={() => openEditTagGroupModal(group)}> <DropdownMenu.Root>
<DropdownMenu.Trigger class="flex shrink-0 items-center justify-center">
<IconButton
variant="outline"
class="pointer-events-none opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
aria-label={$t('tags_admin.actions.actions_menu')}
>
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEditTagGroupModal(group)}>
{$t('tags_admin.actions.edit_group')} {$t('tags_admin.actions.edit_group')}
</Button> </DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div> </div>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
@@ -246,7 +261,7 @@
</Table.Row> </Table.Row>
{:else} {:else}
{#each group.tags as tag (tag.id)} {#each group.tags as tag (tag.id)}
<Table.Row> <Table.Row class="group">
<Table.Cell> <Table.Cell>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
@@ -269,9 +284,22 @@
</Table.Cell> </Table.Cell>
<Table.Cell>{group.name}</Table.Cell> <Table.Cell>{group.name}</Table.Cell>
<Table.Cell class="text-right"> <Table.Cell class="text-right">
<Button variant="ghost-default" size="sm" onclick={() => openEditTagModal(tag)}> <DropdownMenu.Root>
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
<IconButton
variant="outline"
class="pointer-events-none opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
aria-label={$t('tags_admin.actions.actions_menu')}
>
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEditTagModal(tag)}>
{$t('tags_admin.actions.edit_tag')} {$t('tags_admin.actions.edit_tag')}
</Button> </DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
@@ -14,15 +14,23 @@
interface Props { interface Props {
activity: Activity; activity: Activity;
className?: string; className?: string;
classes?: {
container?: string;
icon?: string;
title?: string;
percentage?: string;
description?: string;
};
} }
let { activity, className = '' }: Props = $props(); let { activity, className = '', classes = {} }: Props = $props();
</script> </script>
<div <div
class={cn( class={cn(
'flex w-full flex-col items-center justify-evenly gap-2 rounded-xl border p-3 md:flex-row md:p-5', 'flex w-full flex-col items-center justify-evenly gap-2 rounded-xl border p-3 md:flex-row md:px-5 md:py-3',
className className,
classes.container
)} )}
> >
<div class="bg-primary-200 w-fit rounded-full p-4 text-black"> <div class="bg-primary-200 w-fit rounded-full p-4 text-black">
@@ -30,17 +38,19 @@
</div> </div>
<div> <div>
<p class="text-center text-sm font-medium text-gray-600 md:text-left dark:text-gray-200"> <p class={cn('text-center text-sm font-medium text-gray-600 md:text-left dark:text-gray-200', classes.title)}>
{activity.title} {activity.title}
</p> </p>
<p class="text-center text-2xl md:text-left"> <p class={cn('text-center text-2xl md:text-left', classes.percentage)}>
{activity.percentage} {activity.percentage}
{#if !activity.hidePercentage} {#if !activity.hidePercentage}
% %
{/if} {/if}
</p> </p>
{#if activity.description} {#if activity.description}
<p class="text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300"> <p
class={cn('text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300', classes.description)}
>
{activity.description} {activity.description}
</p> </p>
{/if} {/if}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import * as Avatar from '@cio/ui/base/avatar'; import * as Avatar from '@cio/ui/base/avatar';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
@@ -19,7 +20,8 @@
hideGoogleAuth?: boolean; hideGoogleAuth?: boolean;
redirectPathname?: string; redirectPathname?: string;
handleSubmit?: () => void; handleSubmit?: () => void;
children?: import('svelte').Snippet; children?: Snippet;
getPasswordAuthAlternative?: Snippet;
} }
let { let {
@@ -30,7 +32,8 @@
hideGoogleAuth = false, hideGoogleAuth = false,
redirectPathname = '', redirectPathname = '',
handleSubmit = () => {}, handleSubmit = () => {},
children children,
getPasswordAuthAlternative
}: Props = $props(); }: Props = $props();
async function signInWithGoogle() { async function signInWithGoogle() {
@@ -100,12 +103,17 @@
<Separator /> <Separator />
<span class="ui:bg-card ui:text-muted-foreground absolute px-2 text-sm"> Or continue With </span> <span class="ui:bg-card ui:text-muted-foreground absolute px-2 text-sm"> Or continue With </span>
</div> </div>
{#if getPasswordAuthAlternative}
{@render getPasswordAuthAlternative()}
{:else}
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full"> <Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
<GoogleIconColored /> <GoogleIconColored />
<span> <span>
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')} {isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span> </span>
</Button> </Button>
{/if}
</div> </div>
{/if} {/if}
</Card.Content> </Card.Content>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { sanitizeHtml } from '@cio/ui/tools/sanitize'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface Props { interface Props {
code?: string; code?: string;
} }
@@ -8,5 +9,5 @@
</script> </script>
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500"> <div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
{@html sanitizeHtml(code)} <SafeHtmlContent content={code} />
</div> </div>
@@ -32,6 +32,7 @@
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { generateSlug } from '@cio/utils/functions'; import { generateSlug } from '@cio/utils/functions';
import { openCoursePreview } from '$features/course/utils/course-preview';
import * as Sidebar from '@cio/ui/base/sidebar'; import * as Sidebar from '@cio/ui/base/sidebar';
import { useSidebar } from '@cio/ui/base/sidebar'; import { useSidebar } from '@cio/ui/base/sidebar';
@@ -164,9 +165,12 @@
syncCourseStore(course); syncCourseStore(course);
} }
async function handlePreview() { function handlePreview() {
const link = `${$currentOrgDomain}/course/${course.slug}`; openCoursePreview({
window.open(link, '_blank'); courseId,
courseSlug: course.slug,
currentOrgDomain: $currentOrgDomain
});
} }
function setter(value: any, setterKey: string) { function setter(value: any, setterKey: string) {
@@ -193,7 +197,7 @@
</Button> </Button>
<HoverableItem> <HoverableItem>
{#snippet children(isHovered)} {#snippet children(isHovered)}
<IconButton onclick={handlePreview} disabled={loading || !course.slug}> <IconButton onclick={handlePreview} disabled={loading} aria-label={$t('course.header.preview')}>
<PreviewIcon {isHovered} size={16} /> <PreviewIcon {isHovered} size={16} />
</IconButton> </IconButton>
{/snippet} {/snippet}
@@ -5,7 +5,7 @@
import { isCourseFree, calcCourseDiscount } from '$lib/utils/functions/course'; import { isCourseFree, calcCourseDiscount } from '$lib/utils/functions/course';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { sanitizeHtml } from '@cio/ui/tools/sanitize'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import HTMLRender from '$features/ui/html-render.svelte'; import HTMLRender from '$features/ui/html-render.svelte';
import PaymentModal from './payment-modal.svelte'; import PaymentModal from './payment-modal.svelte';
import type { Course } from '$features/course/utils/types'; import type { Course } from '$features/course/utils/types';
@@ -175,7 +175,9 @@
<!-- Gift Container --> <!-- Gift Container -->
{#if courseData?.metadata?.reward?.show} {#if courseData?.metadata?.reward?.show}
<div class="flex flex-col items-center border-t border-b border-gray-300 p-10"> <div class="flex flex-col items-center border-t border-b border-gray-300 p-10">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.reward.description', '') as string)}</HTMLRender> <HTMLRender
><SafeHtmlContent content={get(courseData, 'metadata.reward.description', '') as string} /></HTMLRender
>
</div> </div>
{/if} {/if}
</aside> </aside>
@@ -4,7 +4,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { sanitizeHtml } from '@cio/ui/tools/sanitize'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import PlayIcon from '@lucide/svelte/icons/play'; import PlayIcon from '@lucide/svelte/icons/play';
import { getLectureNo } from '$features/course/utils/functions'; import { getLectureNo } from '$features/course/utils/functions';
@@ -215,7 +215,7 @@
</h3> </h3>
<ul class="list font-light"> <ul class="list font-light">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.requirements', ''))}</HTMLRender> <HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} /></HTMLRender>
</ul> </ul>
</NavSection> </NavSection>
{/if} {/if}
@@ -228,7 +228,7 @@
</h3> </h3>
<HTMLRender className="dark:text-white text-sm font-light"> <HTMLRender className="dark:text-white text-sm font-light">
{@html sanitizeHtml(get(courseData, 'metadata.description', ''))} <SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
</HTMLRender> </HTMLRender>
</NavSection> </NavSection>
{/if} {/if}
@@ -238,7 +238,7 @@
<NavSection id="goals"> <NavSection id="goals">
<h3 class="mt-0 mb-3 text-2xl">{$t('course.navItem.landing_page.learn')}</h3> <h3 class="mt-0 mb-3 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
<ul class="list font-light"> <ul class="list font-light">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.goals', ''))}</HTMLRender> <HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.goals', '')} /></HTMLRender>
</ul> </ul>
</NavSection> </NavSection>
{/if} {/if}
@@ -17,6 +17,7 @@ export * from './question';
export { default as UploadWidget } from './upload-widget/upload-widget.svelte'; export { default as UploadWidget } from './upload-widget/upload-widget.svelte';
export { default as PageLoadProgress } from './page-load-progress.svelte'; export { default as PageLoadProgress } from './page-load-progress.svelte';
export { default as PoweredBy } from './upgrade-powered-by.svelte'; export { default as PoweredBy } from './upgrade-powered-by.svelte';
export { default as RefreshPageData } from './refresh-page-data.svelte';
export { default as RoleBasedSecurity } from './role-based-security.svelte'; export { default as RoleBasedSecurity } from './role-based-security.svelte';
export { default as Search } from './search.svelte'; export { default as Search } from './search.svelte';
export { default as SenjaEmbed } from './senja-embed.svelte'; export { default as SenjaEmbed } from './senja-embed.svelte';
@@ -108,7 +108,36 @@ const baseNavConfig: NavItemConfig[] = [
icon: BracesIcon, icon: BracesIcon,
requiresAdmin: true, requiresAdmin: true,
disableWhenNotAdmin: true, disableWhenNotAdmin: true,
matchPattern: '^/org/[^/]+/automation(/.*)?$' useHashUrl: true,
matchPattern: '^/org/[^/]+/automation(/.*)?$',
items: [
{
titleKey: 'automation.tabs.mcp',
path: '/automation/mcp'
},
{
titleKey: 'automation.tabs.zapier',
path: '/automation/zapier'
},
{
titleKey: 'automation.tabs.api',
path: '/automation/api'
}
],
nestedRoutes: [
{
path: 'mcp',
titleKey: 'automation.tabs.mcp'
},
{
path: 'zapier',
titleKey: 'automation.tabs.zapier'
},
{
path: 'api',
titleKey: 'automation.tabs.api'
}
]
}, },
{ {
titleKey: 'org_navigation.settings', titleKey: 'org_navigation.settings',
@@ -0,0 +1,43 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
interface Props {
disabled?: boolean;
onRefresh?: () => Promise<unknown> | unknown;
}
let { disabled = false, onRefresh }: Props = $props();
let isRefreshing = $state(false);
async function handleRefresh() {
if (disabled || isRefreshing) return;
isRefreshing = true;
try {
if (onRefresh) {
await onRefresh();
return;
}
await invalidateAll();
} finally {
isRefreshing = false;
}
}
</script>
<IconButton
onclick={handleRefresh}
disabled={disabled || isRefreshing}
tooltip={t.get('common.refresh')}
tooltipSide="bottom"
aria-label={t.get('common.refresh')}
>
<RefreshCcwIcon class={isRefreshing ? 'animate-spin' : ''} size={16} />
</IconButton>
@@ -0,0 +1,71 @@
// SaaS default CSP domains, baked in at build time.
// Self-hosted (adapter-node) starts with empty lists; runtime domains
// are added via env vars in hooks.server.ts so pre-built Docker images stay configurable.
const saasDefaults = {
scriptSrc: [
'https://assets.cdn.clsrio.com',
'https://cdnjs.cloudflare.com',
'https://*.posthog.com',
'https://*.senja.io',
'https://umami.hz.oncws.com',
'https://www.youtube.com',
'https://youtube.com',
'https://google.com'
],
styleSrc: [
'https://cdn.plyr.io',
'https://unpkg.com/[email protected]/dist/katex.min.css',
'https://assets.cdn.clsrio.com/eqneditor_1.css',
'https://fonts.googleapis.com'
],
connectSrc: [
'https://*.classroomio.com',
'https://classroomio.com',
'https://app.classroomio.com',
'https://api.classroomio.com',
'https://pgrest.classroomio.com',
'https://play.classroomio.com',
'wss://*.classroomio.com',
'https://assets.cdn.clsrio.com',
'https://cdn.plyr.io',
'https://*.posthog.com',
'https://umami.hz.oncws.com',
'https://*.r2.cloudflarestorage.com',
'https://*.senja.io',
'https://*.ytimg.com',
'https://noembed.com'
],
frameSrc: [
'https://www.youtube.com',
'https://youtube.com',
'https://www.youtube-nocookie.com',
'https://www.google.com',
'https://google.com'
],
fontSrc: ['https://fonts.gstatic.com', 'https://cdn.plyr.io'],
mediaSrc: ['https:']
};
/**
* @param {boolean} isSelfHosted
* @param {string | undefined} serverUrl - PUBLIC_SERVER_URL, added to connect-src for SaaS builds
*/
export function getCspDomains(isSelfHosted, serverUrl) {
if (isSelfHosted) {
return {
scriptSrc: [],
styleSrc: [],
connectSrc: [],
frameSrc: [],
fontSrc: [],
mediaSrc: [],
apiOrigin: null
};
}
return {
...saasDefaults,
apiOrigin: serverUrl ?? null
};
}
+75
View File
@@ -0,0 +1,75 @@
function parseCspDomains(value: string | undefined): string[] {
if (!value) return [];
return value
.split(',')
.map((d) => d.trim())
.filter(Boolean)
.map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`));
}
function buildCspExtensions(): Record<string, string[]> {
const allDomains = parseCspDomains(process.env.ALLOWED_EXTERNAL_DOMAINS);
const or = (envVar: string) => (allDomains.length ? allDomains : parseCspDomains(process.env[envVar]));
const scriptSrc = or('CSP_SCRIPT_SRC_DOMAINS');
const styleSrc = or('CSP_STYLE_SRC_DOMAINS');
const connectSrcBase = or('CSP_CONNECT_SRC_DOMAINS');
const frameSrc = or('CSP_FRAME_SRC_DOMAINS');
const fontSrc = or('CSP_FONT_SRC_DOMAINS');
const mediaSrc = or('CSP_MEDIA_SRC_DOMAINS');
const serverUrl = process.env.PUBLIC_SERVER_URL;
const connectSrc = serverUrl ? [...connectSrcBase, serverUrl] : connectSrcBase;
const extensions: Record<string, string[]> = {};
if (scriptSrc.length) extensions['script-src'] = scriptSrc;
if (styleSrc.length) {
extensions['style-src'] = styleSrc;
extensions['style-src-elem'] = styleSrc;
}
if (connectSrc.length) extensions['connect-src'] = connectSrc;
if (frameSrc.length) extensions['frame-src'] = frameSrc;
if (fontSrc.length) extensions['font-src'] = fontSrc;
if (mediaSrc.length) {
extensions['img-src'] = mediaSrc;
extensions['media-src'] = mediaSrc;
}
return extensions;
}
let cachedExtensions: Record<string, string[]> | null = null;
function getExtensions(): Record<string, string[]> {
if (!cachedExtensions) cachedExtensions = buildCspExtensions();
return cachedExtensions;
}
function extendHeader(header: string, extensions: Record<string, string[]>): string {
return header
.split(';')
.map((d) => d.trim())
.filter(Boolean)
.map((directive) => {
const spaceIdx = directive.indexOf(' ');
const name = spaceIdx === -1 ? directive : directive.substring(0, spaceIdx);
const extra = extensions[name];
return extra?.length ? `${directive} ${extra.join(' ')}` : directive;
})
.join('; ');
}
export function applyCspExtensions(response: Response): Response {
const extensions = getExtensions();
if (Object.keys(extensions).length === 0) return response;
for (const name of ['content-security-policy', 'content-security-policy-report-only']) {
const header = response.headers.get(name);
if (header) {
response.headers.set(name, extendHeader(header, extensions));
}
}
return response;
}
@@ -3,6 +3,8 @@ import { browser } from '$app/environment';
export const isHtmlValueEmpty = (html: string) => { export const isHtmlValueEmpty = (html: string) => {
if (!html || typeof html !== 'string') return true; if (!html || typeof html !== 'string') return true;
if (/<svg\b/i.test(html)) return false;
if (!document || !browser) return false; if (!document || !browser) return false;
return getTextFromHTML(html) === ''; return getTextFromHTML(html) === '';
@@ -1,5 +1,6 @@
{ {
"common": { "common": {
"refresh": "Opdater",
"unsaved_changes": { "unsaved_changes": {
"message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?" "message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?"
} }
@@ -1508,6 +1509,10 @@
"placeholder": "Søg efter lektioner eller øvelser", "placeholder": "Søg efter lektioner eller øvelser",
"empty": "Ingen resultater fundet" "empty": "Ingen resultater fundet"
}, },
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": { "sidebar": {
"back_to_course": "Tilbage til kurset", "back_to_course": "Tilbage til kurset",
"content_count": { "content_count": {
@@ -1582,6 +1587,12 @@
} }
}, },
"course_filter": { "course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Dato oprettet", "date_created": "Dato oprettet",
"published": "Udgivet", "published": "Udgivet",
"lessons": "Lektioner" "lessons": "Lektioner"
@@ -1592,6 +1603,9 @@
"clear_all": "Ryd alle", "clear_all": "Ryd alle",
"popover_title": "Filtre", "popover_title": "Filtre",
"sort": "Sorter", "sort": "Sorter",
"order": "Rækkefølge",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tags", "tags": "Tags",
"empty_tags": "Ingen tags tilgængelige" "empty_tags": "Ingen tags tilgængelige"
} }
@@ -2212,7 +2226,6 @@
} }
}, },
"login": { "login": {
"sso_enabled": "{provider} er aktiveret for dit domæne",
"sso_redirect_error": "SSO-logon er forkert konfigureret. Indstil PUBLIC_SERVER_URL til din API-server.", "sso_redirect_error": "SSO-logon er forkert konfigureret. Indstil PUBLIC_SERVER_URL til din API-server.",
"sso_required": "Log venligst ind med SSO. Adgangskodelogin er deaktiveret.", "sso_required": "Log venligst ind med SSO. Adgangskodelogin er deaktiveret.",
"sign_in_with_sso": "Log ind med {provider}", "sign_in_with_sso": "Log ind med {provider}",
@@ -1,5 +1,6 @@
{ {
"common": { "common": {
"refresh": "Aktualisieren",
"unsaved_changes": { "unsaved_changes": {
"message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?" "message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?"
} }
@@ -1508,6 +1509,10 @@
"placeholder": "Suchen Sie nach Lektionen oder Übungen", "placeholder": "Suchen Sie nach Lektionen oder Übungen",
"empty": "Keine Ergebnisse gefunden" "empty": "Keine Ergebnisse gefunden"
}, },
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": { "sidebar": {
"back_to_course": "Zurück zum Kurs", "back_to_course": "Zurück zum Kurs",
"content_count": { "content_count": {
@@ -1582,6 +1587,12 @@
} }
}, },
"course_filter": { "course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Erstellungsdatum", "date_created": "Erstellungsdatum",
"published": "Veröffentlicht", "published": "Veröffentlicht",
"lessons": "Lektionen" "lessons": "Lektionen"
@@ -1592,6 +1603,9 @@
"clear_all": "Alles löschen", "clear_all": "Alles löschen",
"popover_title": "Filter", "popover_title": "Filter",
"sort": "Sortieren", "sort": "Sortieren",
"order": "Reihenfolge",
"ascending": "Asc",
"descending": "Desc",
"tags": "Schlagworte", "tags": "Schlagworte",
"empty_tags": "Keine Tags verfügbar" "empty_tags": "Keine Tags verfügbar"
} }
@@ -2212,7 +2226,6 @@
} }
}, },
"login": { "login": {
"sso_enabled": "{provider} ist für Ihre Domain aktiviert",
"sso_redirect_error": "Die SSO-Anmeldung ist falsch konfiguriert. Bitte legen Sie PUBLIC_SERVER_URL auf Ihren API-Server fest.", "sso_redirect_error": "Die SSO-Anmeldung ist falsch konfiguriert. Bitte legen Sie PUBLIC_SERVER_URL auf Ihren API-Server fest.",
"sso_required": "Bitte melden Sie sich mit SSO an. Die Passwortanmeldung ist deaktiviert.", "sso_required": "Bitte melden Sie sich mit SSO an. Die Passwortanmeldung ist deaktiviert.",
"sign_in_with_sso": "Melden Sie sich mit {provider} an", "sign_in_with_sso": "Melden Sie sich mit {provider} an",
@@ -93,6 +93,9 @@
"placeholder": "Search" "placeholder": "Search"
} }
}, },
"common": {
"refresh": "Refresh"
},
"login": { "login": {
"welcome": "Welcome back", "welcome": "Welcome back",
"email": "Your email", "email": "Your email",
@@ -240,7 +243,7 @@
}, },
"login": { "login": {
"or_continue_with": "Or continue with email and password", "or_continue_with": "Or continue with email and password",
"sso_enabled": "{provider} is enabled for your domain", "sso_enabled": "{provider} is enabled for this organization",
"sso_redirect_error": "SSO sign-in is misconfigured. Please set PUBLIC_SERVER_URL to your API server.", "sso_redirect_error": "SSO sign-in is misconfigured. Please set PUBLIC_SERVER_URL to your API server.",
"sso_required": "Please sign in with SSO. Password login is disabled.", "sso_required": "Please sign in with SSO. Password login is disabled.",
"sign_in_with_sso": "Sign in with {provider}", "sign_in_with_sso": "Sign in with {provider}",
@@ -566,6 +569,7 @@
"actions": { "actions": {
"create_tag": "Create Tag", "create_tag": "Create Tag",
"create_group": "Create Tag Group", "create_group": "Create Tag Group",
"actions_menu": "Tag actions",
"edit_group": "Edit Group", "edit_group": "Edit Group",
"edit_tag": "Edit Tag", "edit_tag": "Edit Tag",
"save": "Save", "save": "Save",
@@ -865,10 +869,19 @@
"clear_all": "Clear all", "clear_all": "Clear all",
"popover_title": "Filters", "popover_title": "Filters",
"sort": "Sort", "sort": "Sort",
"order": "Order",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tags", "tags": "Tags",
"empty_tags": "No tags available" "empty_tags": "No tags available"
}, },
"course_filter": { "course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Date Created", "date_created": "Date Created",
"published": "Published", "published": "Published",
"lessons": "Lessons" "lessons": "Lessons"
@@ -947,6 +960,10 @@
"placeholder": "Search lessons or exercises", "placeholder": "Search lessons or exercises",
"empty": "No results found" "empty": "No results found"
}, },
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"navItem": { "navItem": {
"news_feed": { "news_feed": {
"heading": "News Feed", "heading": "News Feed",
@@ -2083,6 +2100,10 @@
"created_at_label": "Created:", "created_at_label": "Created:",
"last_used_label": "Last used:", "last_used_label": "Last used:",
"never_used": "Never", "never_used": "Never",
"table_key": "Key",
"table_status": "Status",
"table_created": "Created",
"table_last_used": "Last used",
"scopes_label": "Scopes:", "scopes_label": "Scopes:",
"revoke_confirm": "Revoke this automation key?", "revoke_confirm": "Revoke this automation key?",
"rotate_confirm": "Rotate this automation key?" "rotate_confirm": "Rotate this automation key?"
@@ -2097,9 +2118,7 @@
"keys_description": "Use one MCP key per integration so access can be rotated or revoked cleanly.", "keys_description": "Use one MCP key per integration so access can be rotated or revoked cleanly.",
"rate_limits_title": "Rate limits", "rate_limits_title": "Rate limits",
"rate_limits_description": "Read / write / publish requests allowed per minute.", "rate_limits_description": "Read / write / publish requests allowed per minute.",
"used_label": "Used:", "used_label": "Used:"
"per_key_label": "Per key:",
"per_org_label": "Per org:"
}, },
"keys": { "keys": {
"title": "MCP keys", "title": "MCP keys",
@@ -1,5 +1,6 @@
{ {
"common": { "common": {
"refresh": "Actualizar",
"unsaved_changes": { "unsaved_changes": {
"message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?" "message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?"
} }
@@ -1508,6 +1509,10 @@
"placeholder": "Buscar lecciones o ejercicios", "placeholder": "Buscar lecciones o ejercicios",
"empty": "No se encontraron resultados" "empty": "No se encontraron resultados"
}, },
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": { "sidebar": {
"back_to_course": "volver al curso", "back_to_course": "volver al curso",
"content_count": { "content_count": {
@@ -1582,6 +1587,12 @@
} }
}, },
"course_filter": { "course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Fecha de creación", "date_created": "Fecha de creación",
"published": "Publicado", "published": "Publicado",
"lessons": "Lecciones" "lessons": "Lecciones"
@@ -1592,6 +1603,9 @@
"clear_all": "Borrar todo", "clear_all": "Borrar todo",
"popover_title": "Filtros", "popover_title": "Filtros",
"sort": "ordenar", "sort": "ordenar",
"order": "Orden",
"ascending": "Asc",
"descending": "Desc",
"tags": "Etiquetas", "tags": "Etiquetas",
"empty_tags": "No hay etiquetas disponibles" "empty_tags": "No hay etiquetas disponibles"
} }
@@ -2212,7 +2226,6 @@
} }
}, },
"login": { "login": {
"sso_enabled": "{provider} está habilitado para tu dominio",
"sso_redirect_error": "El inicio de sesión SSO está mal configurado. Configure PUBLIC_SERVER_URL en su servidor API.", "sso_redirect_error": "El inicio de sesión SSO está mal configurado. Configure PUBLIC_SERVER_URL en su servidor API.",
"sso_required": "Inicie sesión con SSO. El inicio de sesión con contraseña está deshabilitado.", "sso_required": "Inicie sesión con SSO. El inicio de sesión con contraseña está deshabilitado.",
"sign_in_with_sso": "Iniciar sesión con {proveedor}", "sign_in_with_sso": "Iniciar sesión con {proveedor}",
@@ -1,5 +1,6 @@
{ {
"common": { "common": {
"refresh": "Actualiser",
"unsaved_changes": { "unsaved_changes": {
"message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?" "message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?"
} }
@@ -1508,6 +1509,10 @@
"placeholder": "Rechercher des leçons ou des exercices", "placeholder": "Rechercher des leçons ou des exercices",
"empty": "Aucun résultat trouvé" "empty": "Aucun résultat trouvé"
}, },
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": { "sidebar": {
"back_to_course": "Retour au cours", "back_to_course": "Retour au cours",
"content_count": { "content_count": {
@@ -1582,6 +1587,12 @@
} }
}, },
"course_filter": { "course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Date de création", "date_created": "Date de création",
"published": "Publié", "published": "Publié",
"lessons": "Leçons" "lessons": "Leçons"
@@ -1592,6 +1603,9 @@
"clear_all": "Tout effacer", "clear_all": "Tout effacer",
"popover_title": "Filtres", "popover_title": "Filtres",
"sort": "Trier", "sort": "Trier",
"order": "Ordre",
"ascending": "Asc",
"descending": "Desc",
"tags": "Balises", "tags": "Balises",
"empty_tags": "Aucune balise disponible" "empty_tags": "Aucune balise disponible"
} }
@@ -2212,7 +2226,6 @@
} }
}, },
"login": { "login": {
"sso_enabled": "{provider} est activé pour votre domaine",
"sso_redirect_error": "La connexion SSO est mal configurée. Veuillez définir PUBLIC_SERVER_URL sur votre serveur API.", "sso_redirect_error": "La connexion SSO est mal configurée. Veuillez définir PUBLIC_SERVER_URL sur votre serveur API.",
"sso_required": "Veuillez vous connecter avec SSO. La connexion par mot de passe est désactivée.", "sso_required": "Veuillez vous connecter avec SSO. La connexion par mot de passe est désactivée.",
"sign_in_with_sso": "Connectez-vous avec {provider}", "sign_in_with_sso": "Connectez-vous avec {provider}",
@@ -1,5 +1,6 @@
{ {
"common": { "common": {
"refresh": "रीफ़्रेश करें",
"unsaved_changes": { "unsaved_changes": {
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?" "message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?"
} }
@@ -1508,6 +1509,10 @@
"placeholder": "पाठ या अभ्यास खोजें", "placeholder": "पाठ या अभ्यास खोजें",
"empty": "कोई परिणाम नहीं मिला" "empty": "कोई परिणाम नहीं मिला"
}, },
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": { "sidebar": {
"back_to_course": "पाठ्यक्रम पर वापस जाएँ", "back_to_course": "पाठ्यक्रम पर वापस जाएँ",
"content_count": { "content_count": {
@@ -1582,6 +1587,12 @@
} }
}, },
"course_filter": { "course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "दिनांक निर्मित", "date_created": "दिनांक निर्मित",
"published": "प्रकाशित", "published": "प्रकाशित",
"lessons": "सबक" "lessons": "सबक"
@@ -1592,6 +1603,9 @@
"clear_all": "सब साफ़ करें", "clear_all": "सब साफ़ करें",
"popover_title": "फिल्टर", "popover_title": "फिल्टर",
"sort": "क्रमबद्ध करें", "sort": "क्रमबद्ध करें",
"order": "क्रम",
"ascending": "Asc",
"descending": "Desc",
"tags": "टैग", "tags": "टैग",
"empty_tags": "कोई टैग उपलब्ध नहीं है" "empty_tags": "कोई टैग उपलब्ध नहीं है"
} }
@@ -2212,7 +2226,6 @@
} }
}, },
"login": { "login": {
"sso_enabled": "आपके डोमेन के लिए {प्रदाता} सक्षम है",
"sso_redirect_error": "SSO साइन-इन ग़लत कॉन्फ़िगर किया गया है. कृपया अपने एपीआई सर्वर पर PUBLIC_SERVER_URL सेट करें।", "sso_redirect_error": "SSO साइन-इन ग़लत कॉन्फ़िगर किया गया है. कृपया अपने एपीआई सर्वर पर PUBLIC_SERVER_URL सेट करें।",
"sso_required": "कृपया एसएसओ के साथ साइन इन करें। पासवर्ड लॉगिन अक्षम है.", "sso_required": "कृपया एसएसओ के साथ साइन इन करें। पासवर्ड लॉगिन अक्षम है.",
"sign_in_with_sso": "{प्रदाता} के साथ साइन इन करें", "sign_in_with_sso": "{प्रदाता} के साथ साइन इन करें",
@@ -1,5 +1,6 @@
{ {
"common": { "common": {
"refresh": "Odśwież",
"unsaved_changes": { "unsaved_changes": {
"message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?" "message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?"
} }
@@ -1508,6 +1509,10 @@
"placeholder": "Wyszukaj lekcje lub ćwiczenia", "placeholder": "Wyszukaj lekcje lub ćwiczenia",
"empty": "Nie znaleziono żadnych wyników" "empty": "Nie znaleziono żadnych wyników"
}, },
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": { "sidebar": {
"back_to_course": "Powrót na kurs", "back_to_course": "Powrót na kurs",
"content_count": { "content_count": {
@@ -1582,6 +1587,12 @@
} }
}, },
"course_filter": { "course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Data utworzenia", "date_created": "Data utworzenia",
"published": "Opublikowano", "published": "Opublikowano",
"lessons": "Lekcje" "lessons": "Lekcje"
@@ -1592,6 +1603,9 @@
"clear_all": "Wyczyść wszystko", "clear_all": "Wyczyść wszystko",
"popover_title": "Filtry", "popover_title": "Filtry",
"sort": "Sortuj", "sort": "Sortuj",
"order": "Kolejność",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tagi", "tags": "Tagi",
"empty_tags": "Brak dostępnych tagów" "empty_tags": "Brak dostępnych tagów"
} }
@@ -2212,7 +2226,6 @@
} }
}, },
"login": { "login": {
"sso_enabled": "Usługa {provider} jest włączona w Twojej domenie",
"sso_redirect_error": "Logowanie jednokrotne jest błędnie skonfigurowane. Ustaw PUBLIC_SERVER_URL na swój serwer API.", "sso_redirect_error": "Logowanie jednokrotne jest błędnie skonfigurowane. Ustaw PUBLIC_SERVER_URL na swój serwer API.",
"sso_required": "Proszę zalogować się za pomocą SSO. Logowanie hasłem jest wyłączone.", "sso_required": "Proszę zalogować się za pomocą SSO. Logowanie hasłem jest wyłączone.",
"sign_in_with_sso": "Zaloguj się przez {provider}", "sign_in_with_sso": "Zaloguj się przez {provider}",
@@ -1,5 +1,6 @@
{ {
"common": { "common": {
"refresh": "Atualizar",
"unsaved_changes": { "unsaved_changes": {
"message": "Você tem alterações não salvas. Tem certeza de que deseja sair?" "message": "Você tem alterações não salvas. Tem certeza de que deseja sair?"
} }
@@ -1508,6 +1509,10 @@
"placeholder": "Pesquise aulas ou exercícios", "placeholder": "Pesquise aulas ou exercícios",
"empty": "Nenhum resultado encontrado" "empty": "Nenhum resultado encontrado"
}, },
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": { "sidebar": {
"back_to_course": "De volta ao curso", "back_to_course": "De volta ao curso",
"content_count": { "content_count": {
@@ -1582,6 +1587,12 @@
} }
}, },
"course_filter": { "course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Data de criação", "date_created": "Data de criação",
"published": "Publicado", "published": "Publicado",
"lessons": "Lições" "lessons": "Lições"
@@ -1592,6 +1603,9 @@
"clear_all": "Limpar tudo", "clear_all": "Limpar tudo",
"popover_title": "Filtros", "popover_title": "Filtros",
"sort": "Classificar", "sort": "Classificar",
"order": "Ordem",
"ascending": "Asc",
"descending": "Desc",
"tags": "Etiquetas", "tags": "Etiquetas",
"empty_tags": "Nenhuma tag disponível" "empty_tags": "Nenhuma tag disponível"
} }
@@ -2212,7 +2226,6 @@
} }
}, },
"login": { "login": {
"sso_enabled": "{provider} está ativado para seu domínio",
"sso_redirect_error": "O login do SSO está configurado incorretamente. Defina PUBLIC_SERVER_URL para seu servidor API.", "sso_redirect_error": "O login do SSO está configurado incorretamente. Defina PUBLIC_SERVER_URL para seu servidor API.",
"sso_required": "Faça login com SSO. O login por senha está desabilitado.", "sso_required": "Faça login com SSO. O login por senha está desabilitado.",
"sign_in_with_sso": "Faça login com {provedor}", "sign_in_with_sso": "Faça login com {provedor}",
@@ -1,5 +1,6 @@
{ {
"common": { "common": {
"refresh": "Обновить",
"unsaved_changes": { "unsaved_changes": {
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?" "message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?"
} }
@@ -1508,6 +1509,10 @@
"placeholder": "Поиск уроков или упражнений", "placeholder": "Поиск уроков или упражнений",
"empty": "Результаты не найдены" "empty": "Результаты не найдены"
}, },
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": { "sidebar": {
"back_to_course": "Вернуться к курсу", "back_to_course": "Вернуться к курсу",
"content_count": { "content_count": {
@@ -1582,6 +1587,12 @@
} }
}, },
"course_filter": { "course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Дата создания", "date_created": "Дата создания",
"published": "Опубликовано", "published": "Опубликовано",
"lessons": "Уроки" "lessons": "Уроки"
@@ -1592,6 +1603,9 @@
"clear_all": "Очистить все", "clear_all": "Очистить все",
"popover_title": "Фильтры", "popover_title": "Фильтры",
"sort": "Сортировать", "sort": "Сортировать",
"order": "Порядок",
"ascending": "Asc",
"descending": "Desc",
"tags": "Теги", "tags": "Теги",
"empty_tags": "Нет доступных тегов" "empty_tags": "Нет доступных тегов"
} }
@@ -2212,7 +2226,6 @@
} }
}, },
"login": { "login": {
"sso_enabled": "{provider} включен для вашего домена",
"sso_redirect_error": "Вход в систему единого входа настроен неправильно. Установите PUBLIC_SERVER_URL для вашего сервера API.", "sso_redirect_error": "Вход в систему единого входа настроен неправильно. Установите PUBLIC_SERVER_URL для вашего сервера API.",
"sso_required": "Пожалуйста, войдите с помощью SSO. Вход по паролю отключен.", "sso_required": "Пожалуйста, войдите с помощью SSO. Вход по паролю отключен.",
"sign_in_with_sso": "Войдите через {provider}", "sign_in_with_sso": "Войдите через {provider}",
@@ -1,5 +1,6 @@
{ {
"common": { "common": {
"refresh": "Làm mới",
"unsaved_changes": { "unsaved_changes": {
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?" "message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?"
} }
@@ -1508,6 +1509,10 @@
"placeholder": "Tìm kiếm bài học hoặc bài tập", "placeholder": "Tìm kiếm bài học hoặc bài tập",
"empty": "Không tìm thấy kết quả nào" "empty": "Không tìm thấy kết quả nào"
}, },
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": { "sidebar": {
"back_to_course": "Quay lại khóa học", "back_to_course": "Quay lại khóa học",
"content_count": { "content_count": {
@@ -1582,6 +1587,12 @@
} }
}, },
"course_filter": { "course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Ngày tạo", "date_created": "Ngày tạo",
"published": "Đã xuất bản", "published": "Đã xuất bản",
"lessons": "Bài học" "lessons": "Bài học"
@@ -1592,6 +1603,9 @@
"clear_all": "Xóa tất cả", "clear_all": "Xóa tất cả",
"popover_title": "Bộ lọc", "popover_title": "Bộ lọc",
"sort": "Sắp xếp", "sort": "Sắp xếp",
"order": "Thứ tự",
"ascending": "Asc",
"descending": "Desc",
"tags": "Thẻ", "tags": "Thẻ",
"empty_tags": "Không có thẻ nào" "empty_tags": "Không có thẻ nào"
} }
@@ -2212,7 +2226,6 @@
} }
}, },
"login": { "login": {
"sso_enabled": "{provider} đã được bật cho miền của bạn",
"sso_redirect_error": "Đăng nhập SSO bị định cấu hình sai. Vui lòng đặt PUBLIC_SERVER_URL cho máy chủ API của bạn.", "sso_redirect_error": "Đăng nhập SSO bị định cấu hình sai. Vui lòng đặt PUBLIC_SERVER_URL cho máy chủ API của bạn.",
"sso_required": "Vui lòng đăng nhập bằng SSO. Đăng nhập bằng mật khẩu bị vô hiệu hóa.", "sso_required": "Vui lòng đăng nhập bằng SSO. Đăng nhập bằng mật khẩu bị vô hiệu hóa.",
"sign_in_with_sso": "Đăng nhập bằng {nhà cung cấp}", "sign_in_with_sso": "Đăng nhập bằng {nhà cung cấp}",
@@ -15,6 +15,8 @@
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { ssoApi } from '$features/org/api/sso.svelte'; import { ssoApi } from '$features/org/api/sso.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield'; import ShieldIcon from '@lucide/svelte/icons/shield';
import { buildSsoRedirectUrl, createSsoEmailChecker, type SsoAuthState } from '$features/auth/utils/auth-sso';
import { authSsoStore, ensureSsoInfoLoaded } from '$features/auth/utils/auth-sso-store';
const emailFromUrl = page.url.searchParams.get('email') ?? ''; const emailFromUrl = page.url.searchParams.get('email') ?? '';
const isEmailPrefilled = !!emailFromUrl; const isEmailPrefilled = !!emailFromUrl;
@@ -24,102 +26,45 @@
let submitError: string | undefined = $state(); let submitError: string | undefined = $state();
let loading = $state(false); let loading = $state(false);
let errors = $state(Object.assign({}, LOGIN_FIELDS)); let errors = $state(Object.assign({}, LOGIN_FIELDS));
let isSSOInfoLoaded = $state(false); /** Per-email discovery result; display uses this over org-level when set */
let orgSupportsSso = $state(false); let discoveryState = $state<SsoAuthState | null>(null);
// SSO state
let ssoState = $state<{
checking: boolean;
required: boolean;
available: boolean;
redirectUrl: string | null;
providerName: string | null;
providerId: string | null;
}>({
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
});
const redirectUrl = $derived(page.url.searchParams.get('redirect')); const redirectUrl = $derived(page.url.searchParams.get('redirect'));
const hideGoogleAuth = $derived(!!($globalStore.isOrgSite && $currentOrg.disableGoogleAuth)); const hideGoogleAuth = $derived(!!($globalStore.isOrgSite && $currentOrg.disableGoogleAuth));
async function initSSOInfo() { const ssoState = $derived(discoveryState ?? $authSsoStore.ssoState);
if (isSSOInfoLoaded || !$currentOrg.id) return; const orgSupportsSso = $derived($authSsoStore.orgSupportsSso);
isSSOInfoLoaded = true;
const ssoInfo = await ssoApi.getOrgSsoInfo($currentOrg.id);
console.log('ssoInfo', ssoInfo);
if (ssoInfo) {
orgSupportsSso = ssoInfo.hasSso;
ssoState = {
checking: false,
required: ssoInfo.ssoRequired,
available: ssoInfo.hasSso,
redirectUrl: ssoInfo.redirectUrl,
providerName: ssoInfo.providerName,
providerId: ssoInfo.providerId
};
}
}
$effect(() => { $effect(() => {
initSSOInfo(); const orgId = $currentOrg.id;
if (orgId) ensureSsoInfoLoaded(orgId, (id) => ssoApi.getOrgSsoInfo(id));
}); });
let emailCheckTimeout: ReturnType<typeof setTimeout>; const handleEmailChange = createSsoEmailChecker({
function handleEmailChange() { getEmail: () => fields.email,
getOrgSupportsSso: () => orgSupportsSso,
discoverSso: (email) => ssoApi.discoverSso(email),
onChecking: () => {
discoveryState = { ...$authSsoStore.ssoState, checking: true };
},
onResult: (state) => {
discoveryState = state;
},
onClear: () => {
errors.email = ''; errors.email = '';
if (!orgSupportsSso) return; discoveryState = null;
clearTimeout(emailCheckTimeout);
emailCheckTimeout = setTimeout(async () => {
if (!fields.email || !fields.email.includes('@')) {
ssoState = {
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
};
return;
}
ssoState.checking = true;
const result = await ssoApi.discoverSso(fields.email);
ssoState.checking = false;
if (result) {
ssoState = {
checking: false,
required: result.ssoRequired,
available: result.ssoAvailable,
redirectUrl: result.redirectUrl,
providerName: result.providerName,
providerId: result.providerId
};
}
}, 500);
} }
});
async function handleSsoLogin() { async function handleSsoLogin() {
const url = `${window.location.origin}${ssoState.redirectUrl || ''}`; const url = buildSsoRedirectUrl(ssoState.redirectUrl);
const res = await authClient.signIn.sso({ await authClient.signIn.sso({
email: fields.email, email: fields.email,
providerId: ssoState.providerId || '', providerId: ssoState.providerId || '',
callbackURL: url callbackURL: url
}); });
console.log('res', res);
} }
async function handleSubmit() { async function handleSubmit() {
@@ -182,7 +127,32 @@
<title>Welcome back to {$currentOrg.name || 'ClassroomIO'}</title> <title>Welcome back to {$currentOrg.name || 'ClassroomIO'}</title>
</svelte:head> </svelte:head>
<AuthUI isLogin={true} {handleSubmit} isLoading={loading} {hideGoogleAuth}> {#snippet getPasswordAuthAlternative()}
<!-- SSO Section (when SSO is available) -->
<div class="space-y-3">
{#if ssoState.required}
<div class="flex items-center gap-3">
<ShieldIcon class="size-5 text-blue-600" />
<div class="flex-1">
<p class="mt-1 text-xs text-blue-600">
{$t('settings.auth.login.sso_required')}
</p>
</div>
</div>
{/if}
<Button type="button" variant="outline" class="w-full" onclick={handleSsoLogin}>
{$t('settings.auth.login.sign_in_with_sso', { provider: ssoState.providerName || 'SSO' })}
</Button>
</div>
{/snippet}
<AuthUI
isLogin={true}
{handleSubmit}
isLoading={loading}
{hideGoogleAuth}
getPasswordAuthAlternative={ssoState.available ? getPasswordAuthAlternative : undefined}
>
<div class="ui:flex ui:flex-col ui:gap-6"> <div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field> <Field.Field>
<Field.Label for="email">{$t('login.email')}</Field.Label> <Field.Label for="email">{$t('login.email')}</Field.Label>
@@ -205,44 +175,6 @@
</Field.Content> </Field.Content>
</Field.Field> </Field.Field>
<!-- SSO Section (when SSO is available) -->
{#if ssoState.available}
<div
class="ui:p-4 ui:bg-blue-50 ui:dark:bg-blue-900/20 ui:rounded-lg ui:border ui:border-blue-200 ui:dark:border-blue-800"
>
<div class="ui:flex ui:items-center ui:gap-3">
<ShieldIcon class="ui:size-5 ui:text-blue-600" />
<div class="ui:flex-1">
<p class="ui:font-medium ui:text-sm">
{$t('settings.auth.login.sso_enabled', { provider: ssoState.providerName || 'SSO' })}
</p>
{#if ssoState.required}
<p class="ui:text-xs ui:text-blue-600 ui:mt-1">
{$t('settings.auth.login.sso_required')}
</p>
{/if}
</div>
</div>
<Button type="button" variant="default" class="ui:w-full ui:mt-3" onclick={handleSsoLogin}>
{$t('settings.auth.login.sign_in_with_sso', { provider: ssoState.providerName || 'SSO' })}
</Button>
</div>
{/if}
<!-- Divider when both SSO and password login are available -->
{#if ssoState.available && !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)}
<div class="ui:relative ui:my-4">
<div class="ui:absolute ui:inset-0 ui:flex ui:items-center" aria-hidden="true">
<div class="ui:w-full ui:border-t ui:border-border"></div>
</div>
<div class="ui:relative ui:flex ui:justify-center ui:text-xs">
<span class="ui:bg-card ui:px-2 ui:text-muted-foreground">
{$t('settings.auth.login.or_continue_with')}
</span>
</div>
</div>
{/if}
<!-- Password Section (when not force SSO and email/password not disabled) --> <!-- Password Section (when not force SSO and email/password not disabled) -->
{#if !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)} {#if !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)}
<Field.Field> <Field.Field>
@@ -16,6 +16,10 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { ssoApi } from '$features/org/api/sso.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield';
import { buildSsoRedirectUrl, createSsoEmailChecker, type SsoAuthState } from '$features/auth/utils/auth-sso';
import { authSsoStore, ensureSsoInfoLoaded } from '$features/auth/utils/auth-sso-store';
let { data } = $props(); let { data } = $props();
const emailFromUrl = page.url.searchParams.get('email') ?? ''; const emailFromUrl = page.url.searchParams.get('email') ?? '';
@@ -29,6 +33,9 @@
} = $state({}); } = $state({});
let submitError: string = $state(''); let submitError: string = $state('');
/** Per-email discovery result; display uses this over org-level when set */
let discoveryState = $state<SsoAuthState | null>(null);
const disableSubmit = $derived(getDisableSubmit(fields)); const disableSubmit = $derived(getDisableSubmit(fields));
const redirectUrl = $derived(page.url.searchParams.get('redirect')); const redirectUrl = $derived(page.url.searchParams.get('redirect'));
const inviteToken = $derived(page.url.searchParams.get('invite_token')); const inviteToken = $derived(page.url.searchParams.get('invite_token'));
@@ -36,6 +43,9 @@
// Use org from store or layout data (layout data ensures org is available on self-hosted before store is set) // Use org from store or layout data (layout data ensures org is available on self-hosted before store is set)
const org = $derived($currentOrg?.id ? $currentOrg : (data.org ?? $currentOrg)); const org = $derived($currentOrg?.id ? $currentOrg : (data.org ?? $currentOrg));
const ssoState = $derived(discoveryState ?? $authSsoStore.ssoState);
const orgSupportsSso = $derived($authSsoStore.orgSupportsSso);
// Invite context: allow signup when invite_token present or redirect contains invite info // Invite context: allow signup when invite_token present or redirect contains invite info
const hasInviteContext = $derived( const hasInviteContext = $derived(
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token'))) !!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token')))
@@ -54,12 +64,48 @@
} }
}); });
$effect(() => {
const orgId = org?.id;
if (orgId) ensureSsoInfoLoaded(orgId, (id) => ssoApi.getOrgSsoInfo(id));
});
const handleEmailChange = createSsoEmailChecker({
getEmail: () => fields.email,
getOrgSupportsSso: () => orgSupportsSso,
discoverSso: (email) => ssoApi.discoverSso(email),
onChecking: () => {
discoveryState = { ...$authSsoStore.ssoState, checking: true };
},
onResult: (state) => {
discoveryState = state;
},
onClear: () => {
errors.email = '';
discoveryState = null;
}
});
async function handleSsoLogin() {
const url = buildSsoRedirectUrl(ssoState.redirectUrl);
await authClient.signIn.sso({
email: fields.email,
providerId: ssoState.providerId || '',
callbackURL: url
});
}
async function handleSubmit() { async function handleSubmit() {
if (signupRestricted) { if (signupRestricted) {
submitError = t.get('settings.auth.login.signup_disabled_error'); submitError = t.get('settings.auth.login.signup_disabled_error');
return; return;
} }
if (ssoState.required && ssoState.available) {
handleSsoLogin();
return;
}
const validationRes = authValidation(fields); const validationRes = authValidation(fields);
console.log('validationRes', validationRes); console.log('validationRes', validationRes);
@@ -151,7 +197,31 @@
</Button> </Button>
</div> </div>
{:else} {:else}
<AuthUI isLogin={false} {handleSubmit} isLoading={loading} {hideGoogleAuth}> {#snippet getPasswordAuthAlternative()}
<div class="space-y-3">
{#if ssoState.required}
<div class="flex items-center gap-3">
<ShieldIcon class="size-5 text-blue-600" />
<div class="flex-1">
<p class="mt-1 text-xs text-blue-600">
{$t('settings.auth.login.sso_required')}
</p>
</div>
</div>
{/if}
<Button type="button" variant="outline" class="w-full" onclick={handleSsoLogin}>
{$t('settings.auth.login.sign_in_with_sso', { provider: ssoState.providerName || 'SSO' })}
</Button>
</div>
{/snippet}
<AuthUI
isLogin={false}
{handleSubmit}
isLoading={loading}
{hideGoogleAuth}
getPasswordAuthAlternative={ssoState.available ? getPasswordAuthAlternative : undefined}
>
<div class="ui:flex ui:flex-col ui:gap-6"> <div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field> <Field.Field>
<Field.Label for="email">{$t('login.fields.email')}</Field.Label> <Field.Label for="email">{$t('login.fields.email')}</Field.Label>
@@ -160,6 +230,7 @@
id="email" id="email"
type="email" type="email"
bind:value={fields.email} bind:value={fields.email}
oninput={handleEmailChange}
placeholder="[email protected]" placeholder="[email protected]"
disabled={loading || isEmailPrefilled} disabled={loading || isEmailPrefilled}
readonly={isEmailPrefilled} readonly={isEmailPrefilled}
@@ -173,6 +244,7 @@
</Field.Content> </Field.Content>
</Field.Field> </Field.Field>
{#if !ssoState.required}
<Field.Field> <Field.Field>
<Field.Label for="password">{$t('login.fields.password')}</Field.Label> <Field.Label for="password">{$t('login.fields.password')}</Field.Label>
<Field.Content> <Field.Content>
@@ -215,6 +287,7 @@
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full"> <Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
{$t('login.create_account')} {$t('login.create_account')}
</Button> </Button>
{/if}
</div> </div>
</AuthUI> </AuthUI>
{/if} {/if}
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { NewsFeedPage } from '$features/course/pages'; import { NewsFeedPage } from '$features/course/pages';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { RoleBasedSecurity } from '$features/ui'; import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { AccountOrg } from '$features/app/types'; import type { AccountOrg } from '$features/app/types';
@@ -25,7 +26,7 @@
<RoleBasedSecurity <RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)} allowedRoles={getPageRoles($currentOrg)}
onDenied={() => { onDenied={() => {
goto(`/courses/${data.courseId}/lessons?next=true`); goto(resolve(`/courses/${data.courseId}/lessons?next=true`, {}));
}} }}
> >
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl"> <Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
@@ -36,9 +37,12 @@
</Page.Title> </Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
<Button class="mr-2" onclick={() => newsfeedApi.openNewFeedModal()}> <div class="flex items-center gap-2">
<Button onclick={() => newsfeedApi.openNewFeedModal()}>
{$t('course.navItem.news_feed.heading_button.title')} {$t('course.navItem.news_feed.heading_button.title')}
</Button> </Button>
<RefreshPageData onRefresh={() => newsfeedApi.list(data.courseId)} />
</div>
</Page.Action> </Page.Action>
</Page.Header> </Page.Header>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { AnalyticsPage } from '$features/course/pages'; import { AnalyticsPage } from '$features/course/pages';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
@@ -13,6 +14,9 @@
{$t('analytics.title')} {$t('analytics.title')}
</Page.Title> </Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action>
<RefreshPageData />
</Page.Action>
</Page.Header> </Page.Header>
<Page.Body> <Page.Body>
{#snippet child()} {#snippet child()}
@@ -1,7 +1,10 @@
<script lang="ts"> <script lang="ts">
import { AttendancePage } from '$features/course/pages'; import { AttendancePage } from '$features/course/pages';
import { courseApi } from '$features/course/api';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
let { data } = $props(); let { data } = $props();
</script> </script>
@@ -13,6 +16,9 @@
{$t('course.navItem.attendance.title')} {$t('course.navItem.attendance.title')}
</Page.Title> </Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action>
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</Page.Action>
</Page.Header> </Page.Header>
<Page.Body> <Page.Body>
{#snippet child()} {#snippet child()}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { CertificatesPage } from '$features/course/pages'; import { CertificatesPage } from '$features/course/pages';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
</script> </script>
@@ -11,6 +12,9 @@
{$t('course.navItem.certificates.title')} {$t('course.navItem.certificates.title')}
</Page.Title> </Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action>
<RefreshPageData />
</Page.Action>
</Page.Header> </Page.Header>
<Page.Body> <Page.Body>
{#snippet child()} {#snippet child()}
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { LessonsPage } from '$features/course/pages'; import { LessonsPage } from '$features/course/pages';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui'; import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store'; import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
let { data } = $props(); let { data } = $props();
@@ -38,6 +39,7 @@
>{$t('course.navItem.lessons.add_content')}</Button >{$t('course.navItem.lessons.add_content')}</Button
> >
</RoleBasedSecurity> </RoleBasedSecurity>
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</div> </div>
</Page.Action> </Page.Action>
</Page.Header> </Page.Header>
@@ -18,7 +18,6 @@
$effect(() => { $effect(() => {
if (!data.lesson) return; if (!data.lesson) return;
const lesson = data.lesson; const lesson = data.lesson;
if (lessonApi.lesson?.id === lesson.id) return; if (lessonApi.lesson?.id === lesson.id) return;
// Set lesson data // Set lesson data
@@ -13,6 +13,7 @@
import * as DropdownMenu from '@cio/ui/base/dropdown-menu'; import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { generateMarksCSV, generateMarksPDF } from '$features/course/utils/marks-utils'; import { generateMarksCSV, generateMarksPDF } from '$features/course/utils/marks-utils';
import { RefreshPageData } from '$features/ui';
let { data } = $props(); let { data } = $props();
@@ -97,6 +98,7 @@
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
</RoleBasedSecurity> </RoleBasedSecurity>
<RefreshPageData />
</div> </div>
</Page.Action> </Page.Action>
</Page.Header> </Page.Header>
@@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui'; import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left'; import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
let { data = $bindable(), children } = $props(); let { data = $bindable(), children } = $props();
@@ -14,17 +17,20 @@
let backUrl = $derived(page.url.searchParams.get('back')); let backUrl = $derived(page.url.searchParams.get('back'));
const handleClick = () => { const handleClick = () => {
const url = page.url.href + '?add=true'; goto(resolve(`${page.url.pathname}?add=true`, {}));
goto(url);
}; };
const handleBackNavigation = () => { const handleBackNavigation = () => {
if (backUrl) { if (backUrl) {
goto(backUrl); goto(resolve(backUrl, {}));
} else { } else {
goto(`/courses/${data.courseId}/people`); goto(resolve(`/courses/${data.courseId}/people`, {}));
} }
}; };
async function refreshPeoplePage() {
await Promise.all([courseApi.refreshCourse(data.courseId, $profile.id), invalidateAll()]);
}
</script> </script>
<Page.Root class="mx-auto w-[90%] md:max-w-3xl"> <Page.Root class="mx-auto w-[90%] md:max-w-3xl">
@@ -42,13 +48,16 @@
</Page.Title> </Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
<div class="flex items-center gap-2">
{#if !data.personId} {#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}> <RoleBasedSecurity allowedRoles={[1, 2]}>
<Button class="mr-2" onclick={handleClick}> <Button onclick={handleClick}>
{$t('course.navItem.people.add')} {$t('course.navItem.people.add')}
</Button> </Button>
</RoleBasedSecurity> </RoleBasedSecurity>
{/if} {/if}
<RefreshPageData onRefresh={refreshPeoplePage} />
</div>
</Page.Action> </Page.Action>
</Page.Header> </Page.Header>
@@ -3,9 +3,13 @@
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import { RefreshPageData } from '$features/ui';
let settingsComponent: CourseSettingsPage | null = $state(null); let settingsComponent: CourseSettingsPage | null = $state(null);
let isSaving = $state(false); let isSaving = $state(false);
let { data } = $props();
async function handleSave() { async function handleSave() {
isSaving = true; isSaving = true;
@@ -25,9 +29,12 @@
</Page.Title> </Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
<div class="flex items-center gap-2">
<Button variant="secondary" loading={isSaving} onclick={handleSave}> <Button variant="secondary" loading={isSaving} onclick={handleSave}>
{$t('course.navItem.settings.save')} {$t('course.navItem.settings.save')}
</Button> </Button>
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</div>
</Page.Action> </Page.Action>
</Page.Header> </Page.Header>
<Page.Body> <Page.Body>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { SubmissionsPage } from '$features/course/pages'; import { SubmissionsPage } from '$features/course/pages';
import { RoleBasedSecurity } from '$features/ui'; import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
@@ -15,6 +15,9 @@
{$t('course.navItem.submissions.title')} {$t('course.navItem.submissions.title')}
</Page.Title> </Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action>
<RefreshPageData />
</Page.Action>
</Page.Header> </Page.Header>
<Page.Body> <Page.Body>
@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params, url }) => {
throw redirect(307, `/org/${params.slug}/automation/mcp${url.search}`);
};
@@ -1,22 +1,22 @@
<script> <script>
import { AutomationPage } from '$features/automation/pages'; import { ApiAutomationPage } from '$features/automation/pages';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
</script> </script>
<svelte:head> <svelte:head>
<title>{t.get('automation.title')} - ClassroomIO</title> <title>{t.get('automation.tabs.api')} - ClassroomIO</title>
</svelte:head> </svelte:head>
<Page.Header> <Page.Header>
<Page.HeaderContent> <Page.HeaderContent>
<Page.Title>{$t('automation.title')}</Page.Title> <Page.Title>{$t('automation.tabs.api')}</Page.Title>
<Page.Subtitle>{$t('automation.subtitle')}</Page.Subtitle> <Page.Subtitle>{$t('automation.coming_soon')}</Page.Subtitle>
</Page.HeaderContent> </Page.HeaderContent>
</Page.Header> </Page.Header>
<Page.Body> <Page.Body>
{#snippet child()} {#snippet child()}
<AutomationPage /> <ApiAutomationPage />
{/snippet} {/snippet}
</Page.Body> </Page.Body>
@@ -0,0 +1,23 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ parent, cookies }) => {
const { orgId } = await parent();
if (!orgId) {
return { keys: [], usage: null };
}
const headers = getApiHeaders(cookies, orgId);
const [keysResponse, usageResponse] = await Promise.all([
classroomio.organization.automation.keys.$get({ query: { type: 'mcp' } }, headers),
classroomio.organization.automation.usage.$get({ query: { type: 'mcp' } }, headers)
]);
const [keysData, usageData] = await Promise.all([keysResponse.json(), usageResponse.json()]);
return {
keys: keysData.success ? keysData.data : [],
usage: usageData.success ? usageData.data : null
};
};
@@ -0,0 +1,34 @@
<script lang="ts">
import { McpAutomationPage } from '$features/automation/pages';
import { automationApi } from '$features/automation/api/automation.svelte';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
let { data } = $props();
$effect(() => {
if (!data || automationApi.isFetched) return;
automationApi.keys = data.keys;
automationApi.usage = data.usage;
automationApi.isFetched = true;
});
</script>
<svelte:head>
<title>{t.get('automation.tabs.mcp')} - ClassroomIO</title>
</svelte:head>
<Page.Root class="mx-auto w-full max-w-4xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('automation.tabs.mcp')}</Page.Title>
<Page.Subtitle>{$t('automation.subtitle')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<McpAutomationPage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,22 @@
<script>
import { ZapierAutomationPage } from '$features/automation/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>{t.get('automation.tabs.zapier')} - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('automation.tabs.zapier')}</Page.Title>
<Page.Subtitle>{$t('automation.coming_soon')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<ZapierAutomationPage />
{/snippet}
</Page.Body>
@@ -13,11 +13,13 @@
let searchValue = $state(''); let searchValue = $state('');
let selectedId: string = $state('0'); let selectedId: string = $state('0');
let selectedOrder = $state<'asc' | 'desc'>('desc');
let selectedTags = $state<string[]>([]); let selectedTags = $state<string[]>([]);
let hasInitializedFilters = $state(false); let hasInitializedFilters = $state(false);
let isFiltering = $state(false); let isFiltering = $state(false);
const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value)); const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value));
const validSortOrders = new Set(['asc', 'desc']);
$effect(() => { $effect(() => {
if (data.courses) { if (data.courses) {
@@ -37,6 +39,27 @@
return '0'; return '0';
} }
function normalizeSortOrder(sortOrder: string | null): 'asc' | 'desc' {
if (sortOrder && validSortOrders.has(sortOrder)) {
return sortOrder as 'asc' | 'desc';
}
return 'desc';
}
function getLegacySortState(sortValue: string | null) {
switch (sortValue) {
case '3':
return { selectedId: '0', selectedOrder: 'asc' as const };
case '4':
return { selectedId: '1', selectedOrder: 'asc' as const };
case '5':
return { selectedId: '2', selectedOrder: 'asc' as const };
default:
return null;
}
}
function updateFiltersUrl() { function updateFiltersUrl() {
if (!browser || !hasInitializedFilters) { if (!browser || !hasInitializedFilters) {
return; return;
@@ -56,6 +79,12 @@
nextUrl.searchParams.delete('sort'); nextUrl.searchParams.delete('sort');
} }
if (selectedOrder !== 'desc') {
nextUrl.searchParams.set('order', selectedOrder);
} else {
nextUrl.searchParams.delete('order');
}
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
window.history.replaceState(window.history.state, '', nextPath); window.history.replaceState(window.history.state, '', nextPath);
} }
@@ -65,7 +94,8 @@
return; return;
} }
localStorage.setItem('classroomio_filter_course_key', selectedId); localStorage.setItem('classroomio_filter_course_sort_key', selectedId);
localStorage.setItem('classroomio_filter_course_order_key', selectedOrder);
updateFiltersUrl(); updateFiltersUrl();
}); });
@@ -94,6 +124,7 @@
async function clearFilters() { async function clearFilters() {
selectedId = '0'; selectedId = '0';
selectedOrder = 'desc';
if (selectedTags.length === 0) { if (selectedTags.length === 0) {
return; return;
@@ -111,15 +142,27 @@
return false; return false;
}); });
const sortedCourses = [...filteredCourses];
if (selectedId === '0') { if (selectedId === '0') {
return filteredCourses.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); return sortedCourses.sort((a, b) =>
selectedOrder === 'asc'
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
: new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
} else if (selectedId === '1') { } else if (selectedId === '1') {
return filteredCourses.sort((a, b) => (b.isPublished ? 0 : 1) - (a.isPublished ? 0 : 1)); return sortedCourses.sort((a, b) =>
selectedOrder === 'asc'
? Number(a.isPublished) - Number(b.isPublished)
: Number(b.isPublished) - Number(a.isPublished)
);
} else if (selectedId === '2') { } else if (selectedId === '2') {
return filteredCourses.sort((a, b) => b.lessonCount - a.lessonCount); return sortedCourses.sort((a, b) =>
selectedOrder === 'asc' ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount
);
} }
return filteredCourses; return sortedCourses;
}); });
onMount(() => { onMount(() => {
@@ -129,9 +172,32 @@
$courseMetaDeta.view = courseView; $courseMetaDeta.view = courseView;
} }
const sortFromUrl = normalizeSortValue(new URLSearchParams(window.location.search).get('sort')); const searchParams = new URLSearchParams(window.location.search);
const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key')); const legacySortFromUrl = getLegacySortState(searchParams.get('sort'));
selectedId = sortFromUrl !== '0' ? sortFromUrl : sortFromStorage; const legacySortFromStorage = getLegacySortState(localStorage.getItem('classroomio_filter_course_key'));
const oldSortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key'));
const sortFromUrl = normalizeSortValue(searchParams.get('sort'));
const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_sort_key'));
const orderFromUrl = normalizeSortOrder(searchParams.get('order'));
const orderFromStorage = normalizeSortOrder(localStorage.getItem('classroomio_filter_course_order_key'));
if (legacySortFromUrl) {
selectedId = legacySortFromUrl.selectedId;
selectedOrder = legacySortFromUrl.selectedOrder;
} else if (sortFromUrl !== '0' || orderFromUrl !== 'desc') {
selectedId = sortFromUrl;
selectedOrder = orderFromUrl;
} else if (legacySortFromStorage) {
selectedId = legacySortFromStorage.selectedId;
selectedOrder = legacySortFromStorage.selectedOrder;
} else if (sortFromStorage !== '0' || orderFromStorage !== 'desc') {
selectedId = sortFromStorage;
selectedOrder = orderFromStorage;
} else {
selectedId = oldSortFromStorage;
selectedOrder = 'desc';
}
hasInitializedFilters = true; hasInitializedFilters = true;
updateFiltersUrl(); updateFiltersUrl();
}); });
@@ -156,6 +222,7 @@
{#snippet filterControls()} {#snippet filterControls()}
<CourseFilterPopover <CourseFilterPopover
bind:selectedId bind:selectedId
bind:selectedOrder
{selectedTags} {selectedTags}
tagGroups={data.tagGroups} tagGroups={data.tagGroups}
{isFiltering} {isFiltering}
+22 -104
View File
@@ -4,101 +4,19 @@ import adapterAuto from '@sveltejs/adapter-auto';
import adapterNode from '@sveltejs/adapter-node'; import adapterNode from '@sveltejs/adapter-node';
import path from 'path'; import path from 'path';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { getCspDomains } from './src/lib/utils/csp-domains.js';
const useNodeAdapter = process.env.PUBLIC_IS_SELFHOSTED === 'true'; const isSelfHosted = process.env.PUBLIC_IS_SELFHOSTED === 'true';
const API_ORIGIN = process.env.PUBLIC_SERVER_URL;
const IS_CLOUDFLARE = process.env.CI_ENVIRONMENT === 'cloudflare'; const IS_CLOUDFLARE = process.env.CI_ENVIRONMENT === 'cloudflare';
const adapterCloudflare = IS_CLOUDFLARE ? (await import('@sveltejs/adapter-cloudflare')).default : null; const adapterCloudflare = IS_CLOUDFLARE ? (await import('@sveltejs/adapter-cloudflare')).default : null;
const csp = getCspDomains(isSelfHosted, process.env.PUBLIC_SERVER_URL);
/**
* Parse comma-separated domain list from env.
* Normalizes to https:// URLs. Returns null when env var is unset (use defaults).
*/
function parseDomainsFromEnv(envVar) {
const value = process.env[envVar];
if (!value || typeof value !== 'string') return null;
return value
.split(',')
.map((d) => d.trim())
.filter(Boolean)
.map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`));
}
const ALLOWED_EXTERNAL_DOMAINS = parseDomainsFromEnv('ALLOWED_EXTERNAL_DOMAINS');
const ALLOWED_SCRIPT_SRC = parseDomainsFromEnv('CSP_SCRIPT_SRC_DOMAINS');
const ALLOWED_STYLE_SRC = parseDomainsFromEnv('CSP_STYLE_SRC_DOMAINS');
const ALLOWED_CONNECT_SRC = parseDomainsFromEnv('CSP_CONNECT_SRC_DOMAINS');
const ALLOWED_FRAME_SRC = parseDomainsFromEnv('CSP_FRAME_SRC_DOMAINS');
const ALLOWED_FONT_SRC = parseDomainsFromEnv('CSP_FONT_SRC_DOMAINS');
const defaultScriptSrcDomains = [
'https://assets.cdn.clsrio.com',
'https://cdnjs.cloudflare.com',
'https://*.posthog.com',
'https://*.senja.io',
'https://umami.hz.oncws.com',
'https://www.youtube.com',
'https://youtube.com',
'https://google.com'
];
const defaultStyleSrcDomains = [
'https://cdn.plyr.io',
'https://unpkg.com/[email protected]/dist/katex.min.css',
'https://assets.cdn.clsrio.com/eqneditor_1.css',
'https://fonts.googleapis.com'
];
const defaultConnectSrcDomains = [
'https://*.classroomio.com',
'https://classroomio.com',
'https://app.classroomio.com',
'https://api.classroomio.com',
'https://pgrest.classroomio.com',
'https://play.classroomio.com',
'wss://*.classroomio.com',
'https://assets.cdn.clsrio.com',
'https://cdn.plyr.io',
'https://*.posthog.com',
'https://umami.hz.oncws.com',
'https://*.r2.cloudflarestorage.com',
'https://*.senja.io',
'https://*.ytimg.com',
'https://noembed.com'
];
const defaultFrameSrcDomains = [
'https://www.youtube.com',
'https://youtube.com',
'https://www.youtube-nocookie.com',
'https://www.google.com',
'https://google.com'
];
const defaultFontSrcDomains = ['https://fonts.gstatic.com', 'https://cdn.plyr.io'];
// Self-hosted: use ONLY env vars, no defaults (strict CISO allowlists, data residency).
// SaaS: use defaults when env vars are unset (backward compatible).
const scriptSrcDomains = useNodeAdapter
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_SCRIPT_SRC ?? [])
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_SCRIPT_SRC ?? defaultScriptSrcDomains);
const styleSrcDomains = useNodeAdapter
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_STYLE_SRC ?? [])
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_STYLE_SRC ?? defaultStyleSrcDomains);
const connectSrcDomains = useNodeAdapter
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_CONNECT_SRC ?? [])
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_CONNECT_SRC ?? defaultConnectSrcDomains);
const frameSrcDomains = useNodeAdapter
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FRAME_SRC ?? [])
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FRAME_SRC ?? defaultFrameSrcDomains);
const fontSrcDomains = useNodeAdapter
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FONT_SRC ?? [])
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FONT_SRC ?? defaultFontSrcDomains);
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: [vitePreprocess({})], preprocess: [vitePreprocess({})],
kit: { kit: {
adapter: IS_CLOUDFLARE ? adapterCloudflare() : useNodeAdapter ? adapterNode() : adapterAuto(), adapter: IS_CLOUDFLARE ? adapterCloudflare() : isSelfHosted ? adapterNode() : adapterAuto(),
alias: { alias: {
$lib: path.resolve('./src/lib'), $lib: path.resolve('./src/lib'),
$features: path.resolve('./src/lib/features'), $features: path.resolve('./src/lib/features'),
@@ -117,20 +35,20 @@ const config = {
mode: 'auto', mode: 'auto',
directives: { directives: {
'default-src': ['self'], 'default-src': ['self'],
'script-src': ['self', ...scriptSrcDomains, 'unsafe-hashes', 'unsafe-eval'], 'script-src': ['self', ...csp.scriptSrc, 'unsafe-hashes', 'unsafe-eval'],
'style-src': ['self', 'unsafe-inline', ...styleSrcDomains], 'style-src': ['self', 'unsafe-inline', ...csp.styleSrc],
'style-src-elem': ['self', 'unsafe-inline', ...styleSrcDomains], 'style-src-elem': ['self', 'unsafe-inline', ...csp.styleSrc],
'font-src': ['self', ...fontSrcDomains], 'font-src': ['self', ...csp.fontSrc],
'img-src': ['self', 'data:', 'https:', 'blob:', 'http://localhost:9000'], 'img-src': ['self', 'data:', ...csp.mediaSrc, 'blob:', 'http://localhost:9000'],
'media-src': ['self', 'https:', 'data:', 'blob:', 'http://localhost:9000'], 'media-src': ['self', ...csp.mediaSrc, 'data:', 'blob:', 'http://localhost:9000'],
'frame-src': ['self', ...frameSrcDomains], 'frame-src': ['self', ...csp.frameSrc],
'connect-src': [ 'connect-src': [
'self', 'self',
'blob:', 'blob:',
'http://localhost:3002', 'http://localhost:3002',
'http://localhost:9000', 'http://localhost:9000',
...(API_ORIGIN ? [API_ORIGIN] : []), ...(csp.apiOrigin ? [csp.apiOrigin] : []),
...connectSrcDomains ...csp.connectSrc
], ],
'worker-src': ['self', 'blob:'], 'worker-src': ['self', 'blob:'],
'object-src': ['none'], 'object-src': ['none'],
@@ -141,20 +59,20 @@ const config = {
}, },
reportOnly: { reportOnly: {
'default-src': ['self'], 'default-src': ['self'],
'script-src': ['self', ...scriptSrcDomains, 'unsafe-hashes', 'unsafe-eval'], 'script-src': ['self', ...csp.scriptSrc, 'unsafe-hashes', 'unsafe-eval'],
'style-src': ['self', 'unsafe-inline', ...styleSrcDomains], 'style-src': ['self', 'unsafe-inline', ...csp.styleSrc],
'style-src-elem': ['self', 'unsafe-inline', ...styleSrcDomains], 'style-src-elem': ['self', 'unsafe-inline', ...csp.styleSrc],
'font-src': ['self', ...fontSrcDomains], 'font-src': ['self', ...csp.fontSrc],
'img-src': ['self', 'data:', 'https:', 'blob:', 'http://localhost:9000'], 'img-src': ['self', 'data:', ...csp.mediaSrc, 'blob:', 'http://localhost:9000'],
'media-src': ['self', 'https:', 'data:', 'blob:', 'http://localhost:9000'], 'media-src': ['self', ...csp.mediaSrc, 'data:', 'blob:', 'http://localhost:9000'],
'frame-src': ['self', ...frameSrcDomains], 'frame-src': ['self', ...csp.frameSrc],
'connect-src': [ 'connect-src': [
'self', 'self',
'blob:', 'blob:',
'http://localhost:3002', 'http://localhost:3002',
'http://localhost:9000', 'http://localhost:9000',
...(API_ORIGIN ? [API_ORIGIN] : []), ...(csp.apiOrigin ? [csp.apiOrigin] : []),
...connectSrcDomains ...csp.connectSrc
], ],
'worker-src': ['self', 'blob:'], 'worker-src': ['self', 'blob:'],
'object-src': ['none'], 'object-src': ['none'],
+209 -52
View File
@@ -132,6 +132,7 @@ Current tools:
- `list_org_courses` - `list_org_courses`
- `get_course_structure` - `get_course_structure`
- `reorder_course_content`
- `update_course_landing_page` - `update_course_landing_page`
- `create_course_draft` - `create_course_draft`
- `create_course_draft_from_course` - `create_course_draft_from_course`
@@ -172,7 +173,42 @@ Drafts support:
- safer updates to existing courses - safer updates to existing courses
- auditability of AI-generated structure - auditability of AI-generated structure
## Create a New Course Lesson authoring rule:
- `lessonLanguages[].content` should contain only the lesson body HTML
- do not include the lesson title
- do not use `h1` or `h2` in lesson content
- start headings at `h3`
- ClassroomIO already renders the lesson title in the course UI
## After Publish
Once a draft has been published, the live course becomes the safer source of truth.
Recommended agent rule:
- if the draft is still `DRAFT`, continue editing that draft
- if the draft is `PUBLISHED`, do not keep reusing it for major follow-up edits
- instead, read the live course and create a fresh draft from that course
Recommended post-publish sequence:
1. `get_course_structure`
2. `create_course_draft_from_course`
3. `update_course_draft`
4. `publish_course_draft_to_existing_course`
Why:
- teachers may edit the live course in the UI after publish
- another agent session may have changed the course
- reusing an old published draft can overwrite newer live changes
For exercises on an already-published course, prefer the direct exercise tools instead of creating a whole new draft when the user only wants to add or revise exercises.
## User Flows
### Flow 1: Create a new course from a prompt
Typical flow: Typical flow:
@@ -195,26 +231,20 @@ Publish returns:
- use an explicit `bannerImageUrl` - use an explicit `bannerImageUrl`
- generate a random Unsplash-based banner from the course title or a custom query - generate a random Unsplash-based banner from the course title or a custom query
## Update an Existing Course ### Flow 2: Update a landing page with AI-generated copy and media
When the course already exists, use the live course as the source of truth. User says:
Recommended sequence: ```text
Rewrite this course landing page to feel more premium, generate a new cover image, and add three believable reviews.
```
1. `list_org_courses` if the agent needs to resolve the course ID by name Expected tool sequence:
2. `get_course_structure`
3. `create_course_draft_from_course`
4. `update_course_draft`
5. `publish_course_draft_to_existing_course`
This avoids applying broad, blind changes directly to the live course. 1. Agent calls `list_org_courses` if it needs to resolve the course ID by name first.
2. Agent calls `update_course_landing_page`.
## Update a Course Landing Page What the landing-page tool can update:
Use `update_course_landing_page` when the user wants AI help with sales-page style content on an
already-existing course.
This tool can update:
- title - title
- short description - short description
@@ -224,33 +254,167 @@ This tool can update:
- pricing fields - pricing fields
- instructor section - instructor section
- reviews - reviews
- the landing-page image - landing page image
For images, the tool supports either: The tool can either:
- `imageUrl` for an explicit cover image - use an explicit `imageUrl`
- `generateImage` and `imageQuery` for a random Unsplash-derived image - or generate a random Unsplash-based image with `generateImage` and `imageQuery`
## After Publish ### Flow 3: Create a draft from a PDF
Once a draft has been published, the live course becomes the safer source of truth. User says:
Recommended agent rule: ```text
I have my course in a PDF. Extract it and turn it into a ClassroomIO course draft.
```
- if the draft is still `DRAFT`, continue editing that draft Expected tool sequence:
- if the draft is `PUBLISHED`, do not keep reusing it for major follow-up edits
- instead, read the live course and create a fresh draft from that course
Why: 1. Agent reads the PDF itself.
2. Agent restructures the content into normalized JSON.
3. Agent calls `create_course_draft`.
4. User reviews the draft.
5. Agent calls `update_course_draft` if needed.
6. User approves.
7. Agent calls `publish_course_draft`.
- teachers may edit the live course in the UI after publish Important:
- another agent session may have changed the course
- reusing an old published draft can overwrite newer live changes
## Exercises on Live Courses - the PDF does not need to be uploaded to MCP
- the MCP package only receives structured JSON
If the user only wants to add or revise exercises on a published course, prefer the direct ### Flow 4: Tag a draft before publish
exercise tools instead of creating a whole new draft:
User says:
```text
Add tags for algebra, beginner, and exponential growth to this draft.
```
Expected tool sequence:
1. Agent calls `tag_course_draft`.
2. User reviews the updated draft tags.
3. Agent later calls `publish_course_draft`.
Result:
- the draft stores tag names
- publish ensures the tags exist in ClassroomIO
- publish assigns those tags to the live course
### Flow 5: Update an existing course safely
User says:
```text
Take my existing Algebra course, reorganize it, and rewrite the lesson content for a beginner audience.
```
When the course already exists, use the live course as the source of truth.
Expected tool sequence:
1. `list_org_courses` if the agent needs to resolve the course ID by name
2. Agent calls `get_course_structure` with the existing `courseId`.
3. Agent optionally calls `create_course_draft_from_course` to persist a seeded draft.
4. Agent updates the draft with the new structure and content using `update_course_draft`.
5. User reviews the changes.
6. Agent calls `publish_course_draft_to_existing_course`.
What this publish does today:
- updates the existing course title, description, type, and metadata
- can set a generated course cover from Unsplash
- returns the public `courseUrl`
- updates existing sections when the draft keeps their IDs
- updates existing lessons when the draft keeps their IDs
- creates new sections and lessons for draft items without matching live IDs
- upserts lesson language content
- merges draft tags into the existing course
### Flow 6: Reorder live course content
User says:
```text
Move the quiz before lesson 2 and put the recap lesson into the last section.
```
Use `reorder_course_content` when the live course only needs structural moves:
- reorder sections
- reorder lessons or exercises within a section
- move lessons or exercises between sections
This is the fast path for live structure edits and avoids creating a draft when lesson content is not changing.
Expected tool sequence:
1. Agent calls `reorder_course_content`.
Use this direct path when the live course only needs section ordering or lesson or exercise moves between sections.
It avoids the full draft round-trip for simple structural changes.
### Flow 7: Add exercises to a live course
User says:
```text
Add a 5-question multiple-choice exercise to lesson 3.
```
Expected tool sequence:
1. Agent calls `list_course_exercises` if it needs current context.
2. Agent calls `create_course_exercise` for a brand new exercise.
3. Agent calls `update_course_exercise` for later revisions.
Use this direct path when the course is already live and the change is exercise-specific.
What it does not do today:
- automatically delete sections or lessons that are absent from the draft
- automatically delete lesson locales that are absent from the draft
That behavior is intentional. The current update path is non-destructive by default.
## How Existing-Course Updates Work
When a draft is seeded from a live course:
- section `externalId` values are the real section IDs
- lesson `externalId` values are the real lesson IDs
As long as the agent preserves those IDs while editing the draft, publish-to-existing-course can map draft items back to the live records and update them safely.
If the agent adds a brand new section or lesson, it should assign a new synthetic `externalId`. ClassroomIO will create that record on publish.
## Input Contract Notes
For large updates, the agent should:
1. call `get_course_structure`
2. keep existing `externalId` values for records it wants to update
3. only generate new `externalId` values for new records
4. avoid deleting records from the draft unless the user explicitly wants omitted content to remain untouched
Publish input also supports:
- `bannerImageUrl`: use this exact course cover
- `bannerImageQuery`: search Unsplash with this query
- `generateBannerImage`: fetch a random Unsplash image using the course title or query
Tagging tools use:
- `tagNames`: human-readable tag names, not tag IDs
- `mode: "merge" | "replace"`
- `groupName` optional, defaults to `Automation`
The API creates missing tags automatically when needed.
Exercise tools use the live course directly:
- `list_course_exercises` - `list_course_exercises`
- `get_course_exercise` - `get_course_exercise`
@@ -258,8 +422,6 @@ exercise tools instead of creating a whole new draft:
- `create_course_exercise_from_template` - `create_course_exercise_from_template`
- `update_course_exercise` - `update_course_exercise`
Use a draft only when the agent is restructuring broader course content.
Supported `questionTypeId` values for exercise payloads: Supported `questionTypeId` values for exercise payloads:
- `1` `RADIO` - Single answer - `1` `RADIO` - Single answer
@@ -275,29 +437,24 @@ Supported `questionTypeId` values for exercise payloads:
- `11` `HOTSPOT` - Hotspot - `11` `HOTSPOT` - Hotspot
- `12` `LINK` - Links - `12` `LINK` - Links
## Tagging ## Operational Notes
The MCP supports tagging at two levels: - `create_course_draft_from_course` may add warnings if a live course uses legacy lesson notes instead of localized lesson content
- if a course has ungrouped lessons, the seeded draft may add a synthetic `Ungrouped` section so the structure stays valid
- published drafts are treated as finalized snapshots
- `tag_course_draft` ## Recommended Agent Behavior
- set or revise tags before publish
- `tag_courses`
- tag one or more live courses
The live-course tagging tool supports merge-or-replace behavior. Use merge when the user wants to For small edits, an agent can still read the course structure and modify only the affected items in the draft.
add tags without removing existing ones.
## PDF-Based Authoring For broad revisions, prefer this sequence:
If a user wants to turn a PDF into a course: 1. `get_course_structure`
2. `create_course_draft_from_course`
3. `update_course_draft`
4. `publish_course_draft_to_existing_course`
1. the agent reads and extracts the PDF itself That keeps major AI-assisted edits reviewable and explicit.
2. the agent converts the content into normalized course JSON
3. the agent calls `create_course_draft`
4. the user reviews the draft
5. the agent publishes it
The PDF itself is not uploaded to the MCP server.
<Callout title="Operational Rule"> <Callout title="Operational Rule">
Keep published drafts for history and auditability. Do not treat omission from a draft as an Keep published drafts for history and auditability. Do not treat omission from a draft as an
@@ -31,7 +31,7 @@ cd classroomio
cp .env.example .env cp .env.example .env
``` ```
Open `.env` and set `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, `BETTER_AUTH_SECRET`, and `ALLOWED_EXTERNAL_DOMAINS` for your domain. For local development the defaults work as-is. Open `.env` and set `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, and `BETTER_AUTH_SECRET` for your domain. For local development the defaults work as-is.
3. Start the full stack: 3. Start the full stack:
@@ -73,10 +73,23 @@ Docker self-hosting uses a single root `.env` file. Every `docker compose` comma
| `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` | | `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` |
| `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` | | `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` |
| `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` | | `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` |
| `ALLOWED_EXTERNAL_DOMAINS` | CSP allowed domains (comma-separated) | — |
#### CSP Domains (Runtime)
CSP domain variables are **runtime** — set them in your `.env` or `docker-compose.yaml` `environment:` section. No image rebuild is needed when changing them.
| Variable | Description |
|----------|-------------|
| `ALLOWED_EXTERNAL_DOMAINS` | Overrides all per-directive CSP vars (comma-separated) |
| `CSP_SCRIPT_SRC_DOMAINS` | Domains allowed in `script-src` |
| `CSP_STYLE_SRC_DOMAINS` | Domains allowed in `style-src` |
| `CSP_CONNECT_SRC_DOMAINS` | Domains allowed in `connect-src` |
| `CSP_FRAME_SRC_DOMAINS` | Domains allowed in `frame-src` |
| `CSP_FONT_SRC_DOMAINS` | Domains allowed in `font-src` |
| `CSP_MEDIA_SRC_DOMAINS` | Domains allowed in `img-src` and `media-src` |
<Callout type="warn" title="CSP Domains"> <Callout type="warn" title="CSP Domains">
When `PUBLIC_IS_SELFHOSTED=true`, the dashboard has **no default** CSP domains. You must set `ALLOWED_EXTERNAL_DOMAINS` with your CDN, API, video, and font domains. When `PUBLIC_IS_SELFHOSTED=true`, the dashboard starts with only `'self'` allowed. Set `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive vars) with your CDN, API, video, and font domains. Since these are runtime env vars, you can change them by restarting the container — no rebuild required.
</Callout> </Callout>
#### Optional — Email #### Optional — Email
@@ -229,7 +242,7 @@ PRIVATE_SERVER_URL="http://${{api.RAILWAY_PRIVATE_DOMAIN}}:8080"
PUBLIC_SERVER_URL="https://api.yourdomain.com" PUBLIC_SERVER_URL="https://api.yourdomain.com"
``` ```
Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. You can also use per-directive CSP vars (`CSP_SCRIPT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`, etc.) instead of `ALLOWED_EXTERNAL_DOMAINS` for finer control.
<Callout title="PRIVATE_SERVER_KEY"> <Callout title="PRIVATE_SERVER_KEY">
`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard. It authenticates server-side dashboard requests to the API. `PRIVATE_SERVER_KEY` must be the same value in both API and dashboard. It authenticates server-side dashboard requests to the API.
+4 -1
View File
@@ -13,9 +13,12 @@ RUN pnpm install --frozen-lockfile --ignore-scripts --shamefully-hoist --filter
RUN find . -name 'tsconfig.tsbuildinfo' -delete RUN find . -name 'tsconfig.tsbuildinfo' -delete
RUN pnpm -r --filter @cio/api... build RUN pnpm -r --filter @cio/api... build
COPY docker/entrypoint-api.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
EXPOSE 3081 EXPOSE 3081
ENV NODE_ENV=production \ ENV NODE_ENV=production \
PORT=3081 PORT=3081
CMD ["pnpm", "--filter", "@cio/api", "start"] ENTRYPOINT ["/app/entrypoint.sh"]
+1 -17
View File
@@ -5,13 +5,6 @@ WORKDIR /app
ENV CI=true ENV CI=true
ARG PUBLIC_IS_SELFHOSTED=true ARG PUBLIC_IS_SELFHOSTED=true
ARG PUBLIC_SERVER_URL
ARG ALLOWED_EXTERNAL_DOMAINS
ARG CSP_SCRIPT_SRC_DOMAINS
ARG CSP_STYLE_SRC_DOMAINS
ARG CSP_CONNECT_SRC_DOMAINS
ARG CSP_FRAME_SRC_DOMAINS
ARG CSP_FONT_SRC_DOMAINS
RUN corepack enable && corepack prepare [email protected] --activate RUN corepack enable && corepack prepare [email protected] --activate
@@ -19,17 +12,8 @@ COPY . .
RUN pnpm install --frozen-lockfile --ignore-scripts --filter @cio/dashboard... RUN pnpm install --frozen-lockfile --ignore-scripts --filter @cio/dashboard...
RUN find . -name 'tsconfig.tsbuildinfo' -delete RUN find . -name 'tsconfig.tsbuildinfo' -delete
RUN echo "Dashboard build PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}" && \ RUN NODE_OPTIONS="--max-old-space-size=3072" \
echo "Dashboard build PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}" && \
NODE_OPTIONS="--max-old-space-size=3072" \
PUBLIC_IS_SELFHOSTED="${PUBLIC_IS_SELFHOSTED}" \ PUBLIC_IS_SELFHOSTED="${PUBLIC_IS_SELFHOSTED}" \
PUBLIC_SERVER_URL="${PUBLIC_SERVER_URL}" \
ALLOWED_EXTERNAL_DOMAINS="${ALLOWED_EXTERNAL_DOMAINS}" \
CSP_SCRIPT_SRC_DOMAINS="${CSP_SCRIPT_SRC_DOMAINS}" \
CSP_STYLE_SRC_DOMAINS="${CSP_STYLE_SRC_DOMAINS}" \
CSP_CONNECT_SRC_DOMAINS="${CSP_CONNECT_SRC_DOMAINS}" \
CSP_FRAME_SRC_DOMAINS="${CSP_FRAME_SRC_DOMAINS}" \
CSP_FONT_SRC_DOMAINS="${CSP_FONT_SRC_DOMAINS}" \
pnpm -r --filter @cio/dashboard... build pnpm -r --filter @cio/dashboard... build
RUN test -f apps/dashboard/build/index.js || (echo "ERROR: Dashboard build output not found at apps/dashboard/build/index.js" && exit 1) RUN test -f apps/dashboard/build/index.js || (echo "ERROR: Dashboard build output not found at apps/dashboard/build/index.js" && exit 1)
+8 -26
View File
@@ -72,23 +72,6 @@ services:
profiles: profiles:
- minio - minio
db-init:
build:
context: ..
dockerfile: docker/Dockerfile.api
container_name: cio-db-init
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio}
PRIVATE_DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio}
command:
- sh
- -c
- pnpm --filter @cio/db db:setup
restart: "no"
api: api:
build: build:
context: .. context: ..
@@ -101,8 +84,6 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
db-init:
condition: service_completed_successfully
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 3081 PORT: 3081
@@ -137,6 +118,7 @@ services:
LICENSE_KEY: ${LICENSE_KEY:-} LICENSE_KEY: ${LICENSE_KEY:-}
UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-} UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-this} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-this}
SKIP_DB_SETUP: ${SKIP_DB_SETUP:-}
restart: unless-stopped restart: unless-stopped
dashboard: dashboard:
@@ -145,13 +127,6 @@ services:
dockerfile: docker/Dockerfile.dashboard dockerfile: docker/Dockerfile.dashboard
args: args:
PUBLIC_IS_SELFHOSTED: ${PUBLIC_IS_SELFHOSTED:-true} PUBLIC_IS_SELFHOSTED: ${PUBLIC_IS_SELFHOSTED:-true}
PUBLIC_SERVER_URL: ${PUBLIC_SERVER_URL:-http://localhost:3081}
ALLOWED_EXTERNAL_DOMAINS: ${ALLOWED_EXTERNAL_DOMAINS:-}
CSP_SCRIPT_SRC_DOMAINS: ${CSP_SCRIPT_SRC_DOMAINS:-}
CSP_STYLE_SRC_DOMAINS: ${CSP_STYLE_SRC_DOMAINS:-}
CSP_CONNECT_SRC_DOMAINS: ${CSP_CONNECT_SRC_DOMAINS:-}
CSP_FRAME_SRC_DOMAINS: ${CSP_FRAME_SRC_DOMAINS:-}
CSP_FONT_SRC_DOMAINS: ${CSP_FONT_SRC_DOMAINS:-}
container_name: cio-dashboard container_name: cio-dashboard
ports: ports:
- "3082:3082" - "3082:3082"
@@ -169,6 +144,13 @@ services:
PRIVATE_APP_HOST: ${PRIVATE_APP_HOST:-localhost} PRIVATE_APP_HOST: ${PRIVATE_APP_HOST:-localhost}
PRIVATE_APP_SUBDOMAINS: ${PRIVATE_APP_SUBDOMAINS:-app} PRIVATE_APP_SUBDOMAINS: ${PRIVATE_APP_SUBDOMAINS:-app}
AUTH_BEARER_TOKEN: ${AUTH_BEARER_TOKEN:-} AUTH_BEARER_TOKEN: ${AUTH_BEARER_TOKEN:-}
ALLOWED_EXTERNAL_DOMAINS: ${ALLOWED_EXTERNAL_DOMAINS:-}
CSP_SCRIPT_SRC_DOMAINS: ${CSP_SCRIPT_SRC_DOMAINS:-}
CSP_STYLE_SRC_DOMAINS: ${CSP_STYLE_SRC_DOMAINS:-}
CSP_CONNECT_SRC_DOMAINS: ${CSP_CONNECT_SRC_DOMAINS:-}
CSP_FRAME_SRC_DOMAINS: ${CSP_FRAME_SRC_DOMAINS:-}
CSP_FONT_SRC_DOMAINS: ${CSP_FONT_SRC_DOMAINS:-}
CSP_MEDIA_SRC_DOMAINS: ${CSP_MEDIA_SRC_DOMAINS:-}
restart: unless-stopped restart: unless-stopped
volumes: volumes:
-7
View File
@@ -42,13 +42,6 @@ echo "Dashboard build arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}"
docker build \ docker build \
-f docker/Dockerfile.dashboard \ -f docker/Dockerfile.dashboard \
--build-arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED} \ --build-arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED} \
--build-arg PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL:-} \
--build-arg ALLOWED_EXTERNAL_DOMAINS="${ALLOWED_EXTERNAL_DOMAINS:-}" \
--build-arg CSP_SCRIPT_SRC_DOMAINS="${CSP_SCRIPT_SRC_DOMAINS:-}" \
--build-arg CSP_STYLE_SRC_DOMAINS="${CSP_STYLE_SRC_DOMAINS:-}" \
--build-arg CSP_CONNECT_SRC_DOMAINS="${CSP_CONNECT_SRC_DOMAINS:-}" \
--build-arg CSP_FRAME_SRC_DOMAINS="${CSP_FRAME_SRC_DOMAINS:-}" \
--build-arg CSP_FONT_SRC_DOMAINS="${CSP_FONT_SRC_DOMAINS:-}" \
-t ${DOCKERHUB_USERNAME}/dashboard:${VERSION} \ -t ${DOCKERHUB_USERNAME}/dashboard:${VERSION} \
-t ${DOCKERHUB_USERNAME}/dashboard:latest \ -t ${DOCKERHUB_USERNAME}/dashboard:latest \
. .
+1 -1
View File
@@ -158,7 +158,7 @@ The GitHub Actions workflow builds for both `linux/amd64` and `linux/arm64` plat
### Build failures ### Build failures
- Check Dockerfile syntax - Check Dockerfile syntax
- Ensure all build arguments are provided - The only required build argument is `PUBLIC_IS_SELFHOSTED` (defaults to `true`)
- Check available disk space: `docker system df` - Check available disk space: `docker system df`
### Clean up old images ### Clean up old images
+2 -1
View File
@@ -25,7 +25,8 @@ All Docker services read from a single root `.env` file. See [`.env.example`](..
Key points: Key points:
- **Required:** `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, `BETTER_AUTH_SECRET`, `PUBLIC_IS_SELFHOSTED=true`, `ALLOWED_EXTERNAL_DOMAINS`, `DASHBOARD_ORIGIN`. - **Required:** `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, `BETTER_AUTH_SECRET`, `PUBLIC_IS_SELFHOSTED=true`, `DASHBOARD_ORIGIN`.
- **CSP (runtime):** `ALLOWED_EXTERNAL_DOMAINS` (overrides all) or per-directive: `CSP_SCRIPT_SRC_DOMAINS`, `CSP_STYLE_SRC_DOMAINS`, `CSP_CONNECT_SRC_DOMAINS`, `CSP_FRAME_SRC_DOMAINS`, `CSP_FONT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`. These are read at container startup — no image rebuild needed.
- **Auth cookies:** Set `AUTH_COOKIE_DOMAIN` to your root domain (e.g. `.yourdomain.com`) when API and dashboard use different subdomains. Without it, auth cookies may not be set and login will fail. - **Auth cookies:** Set `AUTH_COOKIE_DOMAIN` to your root domain (e.g. `.yourdomain.com`) when API and dashboard use different subdomains. Without it, auth cookies may not be set and login will fail.
- **Auto-generated:** `AUTH_BEARER_TOKEN`, `PRIVATE_SERVER_KEY` (by `./run-docker-full-stack.sh`). - **Auto-generated:** `AUTH_BEARER_TOKEN`, `PRIVATE_SERVER_KEY` (by `./run-docker-full-stack.sh`).
- **Auto-configured:** All `MINIO_*` / `OBJECT_STORAGE_*` vars (by the startup script). - **Auto-configured:** All `MINIO_*` / `OBJECT_STORAGE_*` vars (by the startup script).
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -e
if [ "${SKIP_DB_SETUP}" = "true" ]; then
echo "Skipping database setup (SKIP_DB_SETUP=true)."
else
echo "Running database setup..."
pnpm --filter @cio/db db:setup
echo "Database setup complete."
fi
echo "Starting API..."
exec pnpm --filter @cio/api start
+4 -1
View File
@@ -41,7 +41,10 @@
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"esbuild", "esbuild",
"@tailwindcss/oxide" "@tailwindcss/oxide"
] ],
"patchedDependencies": {
"[email protected]": "patches/[email protected]"
}
}, },
"packageManager": "[email protected]+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8" "packageManager": "[email protected]+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8"
} }
@@ -1,6 +1,6 @@
import * as schema from '@db/schema'; import * as schema from '@db/schema';
import { and, desc, eq, isNull } from 'drizzle-orm'; import { and, count, desc, eq, isNull } from 'drizzle-orm';
import { db } from '@db/drizzle'; import { db } from '@db/drizzle';
import type { TNewOrganizationApiKey, TOrganizationApiKey, TOrganizationApiKeyType } from '@db/types'; import type { TNewOrganizationApiKey, TOrganizationApiKey, TOrganizationApiKeyType } from '@db/types';
@@ -42,6 +42,29 @@ export const listOrganizationApiKeys = async (
} }
}; };
export const countActiveOrganizationApiKeys = async (
organizationId: string,
type: TOrganizationApiKeyType
): Promise<number> => {
try {
const [row] = await db
.select({ total: count() })
.from(schema.organizationApiKey)
.where(
and(
eq(schema.organizationApiKey.organizationId, organizationId),
eq(schema.organizationApiKey.type, type),
isNull(schema.organizationApiKey.revokedAt)
)
);
return Number(row?.total ?? 0);
} catch (error) {
console.error('countActiveOrganizationApiKeys error:', error);
throw new Error('Failed to count active organization API keys');
}
};
export const getOrganizationApiKeyById = async ( export const getOrganizationApiKeyById = async (
organizationId: string, organizationId: string,
keyId: string keyId: string
@@ -0,0 +1,124 @@
import * as schema from '@db/schema';
import { and, count, desc, eq, gte, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
import type { TAutomationUsageCategory, TNewOrganizationAutomationUsage, TOrganizationApiKeyType } from '@db/types';
export const createOrganizationAutomationUsage = async (data: TNewOrganizationAutomationUsage) => {
try {
const [row] = await db.insert(schema.organizationAutomationUsage).values(data).returning();
if (!row) {
throw new Error('Failed to create organization automation usage');
}
return row;
} catch (error) {
console.error('createOrganizationAutomationUsage error:', error);
throw new Error('Failed to create organization automation usage');
}
};
export const countOrganizationAutomationUsageSinceByKey = async (
organizationApiKeyId: string,
category: TAutomationUsageCategory,
since: string
): Promise<number> => {
try {
const [row] = await db
.select({ total: count() })
.from(schema.organizationAutomationUsage)
.where(
and(
eq(schema.organizationAutomationUsage.organizationApiKeyId, organizationApiKeyId),
eq(schema.organizationAutomationUsage.category, category),
gte(schema.organizationAutomationUsage.createdAt, since)
)
);
return Number(row?.total ?? 0);
} catch (error) {
console.error('countOrganizationAutomationUsageSinceByKey error:', error);
throw new Error('Failed to count organization automation usage by key');
}
};
export const countOrganizationAutomationUsageSince = async (
organizationId: string,
type: TOrganizationApiKeyType,
category: TAutomationUsageCategory,
since: string
): Promise<number> => {
try {
const [row] = await db
.select({ total: count() })
.from(schema.organizationAutomationUsage)
.where(
and(
eq(schema.organizationAutomationUsage.organizationId, organizationId),
eq(schema.organizationAutomationUsage.type, type),
eq(schema.organizationAutomationUsage.category, category),
gte(schema.organizationAutomationUsage.createdAt, since)
)
);
return Number(row?.total ?? 0);
} catch (error) {
console.error('countOrganizationAutomationUsageSince error:', error);
throw new Error('Failed to count organization automation usage');
}
};
export const getOrganizationAutomationCreditsUsedSince = async (
organizationId: string,
type: TOrganizationApiKeyType,
since: string
): Promise<number> => {
try {
const [row] = await db
.select({
total: sql<number>`COALESCE(SUM(${schema.organizationAutomationUsage.creditsConsumed}), 0)`
})
.from(schema.organizationAutomationUsage)
.where(
and(
eq(schema.organizationAutomationUsage.organizationId, organizationId),
eq(schema.organizationAutomationUsage.type, type),
gte(schema.organizationAutomationUsage.createdAt, since)
)
);
return Number(row?.total ?? 0);
} catch (error) {
console.error('getOrganizationAutomationCreditsUsedSince error:', error);
throw new Error('Failed to get organization automation credits used');
}
};
export const listRecentOrganizationAutomationUsage = async (
organizationId: string,
type: TOrganizationApiKeyType,
limit = 10
) => {
try {
return await db
.select({
action: schema.organizationAutomationUsage.action,
creditsConsumed: schema.organizationAutomationUsage.creditsConsumed,
createdAt: schema.organizationAutomationUsage.createdAt
})
.from(schema.organizationAutomationUsage)
.where(
and(
eq(schema.organizationAutomationUsage.organizationId, organizationId),
eq(schema.organizationAutomationUsage.type, type)
)
)
.orderBy(desc(schema.organizationAutomationUsage.createdAt))
.limit(limit);
} catch (error) {
console.error('listRecentOrganizationAutomationUsage error:', error);
throw new Error('Failed to list recent organization automation usage');
}
};

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