From 21c07367d664fcfdd128a7ca19e2f7e2f97b4b42 Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Wed, 22 Jul 2026 16:04:18 -0500 Subject: [PATCH 1/2] feat: overhaul role-based UX and organization administration ## PR summary Completes the role-based EchoEd UX overhaul across public, authentication, learner, teacher, Studio, Organization, and Admin experiences. It introduces accessible shared controls, responsive role-aware navigation, production-ready organization administration, scoped backend authorization, and comprehensive visual/accessibility regression coverage. --- .gitignore | 3 + .../3b09bf0f-a596-4cd5-854c-4042ef6e39e0.png | 2 - .../basetemp/test_upload_badge_imagecurrent | 1 - .../f170f98c-d8e4-4078-937f-b6d6c62f0cec.png | 2 - .../test_upload_storybook_pagecurrent | 1 - backend/app/api/routes/analytics.py | 10 +- backend/app/api/routes/assignments.py | 9 +- backend/app/api/routes/orgs.py | 76 +++++++- backend/app/api/routes/sections.py | 21 +- backend/app/schemas.py | 15 ++ backend/app/section_scope.py | 29 +++ backend/tests/test_org_admin_scope.py | 180 ++++++++++++++++++ docs/ux-overhaul/backend-gap-register.md | 8 + docs/ux-overhaul/org-accessibility-review.md | 11 ++ docs/ux-overhaul/org-data-contract.md | 34 ++++ docs/ux-overhaul/org-privacy-review.md | 21 ++ docs/ux-overhaul/org-production-audit.md | 81 ++++++++ .../org-production-implementation.md | 36 ++++ docs/ux-overhaul/org-responsive-review.md | 9 + docs/ux-overhaul/org-role-capability-map.md | 30 +++ .../production-accessibility-visual-review.md | 29 +++ .../ux-overhaul/production-route-migration.md | 14 +- docs/ux-overhaul/shared-component-status.md | 17 +- frontend/package.json | 3 +- frontend/src/app/app.routes.spec.ts | 24 +++ frontend/src/app/app.routes.ts | 23 +++ .../echo-button/echo-button.component.ts | 23 ++- .../echo-button/echo-button.stories.ts | 12 +- .../echo-checkbox/echo-checkbox.component.ts | 25 ++- .../echo-checkbox/echo-checkbox.stories.ts | 4 + .../src/app/components/echo-controls.spec.ts | 101 ++++++++++ .../echo-data-list.component.spec.ts | 50 +++++ .../echo-data-list.component.ts | 116 +++++++++++ .../echo-data-list/echo-data-list.stories.ts | 28 +++ .../echo-header/echo-header.component.html | 2 +- .../echo-input/echo-input.component.ts | 58 +++++- .../echo-input/echo-input.stories.ts | 5 + .../echo-loading-state.component.scss | 4 + .../echo-radio-group.component.ts | 23 ++- .../echo-radio-group.stories.ts | 4 + .../echo-radio/echo-radio.component.ts | 6 +- .../echo-search/echo-search.component.ts | 63 ++++++ .../echo-search/echo-search.stories.ts | 28 +++ .../echo-select/echo-select.component.ts | 43 +++-- .../echo-select/echo-select.stories.ts | 4 + .../echo-state-panel.component.html | 19 +- .../echo-state-panel.component.scss | 19 ++ .../echo-state-panel.component.spec.ts | 14 ++ .../echo-state-panel.component.ts | 5 + .../echo-state-panel.stories.ts | 19 +- .../echo-textarea/echo-textarea.component.ts | 42 ++-- .../echo-textarea/echo-textarea.stories.ts | 4 + .../echo-toggle/echo-toggle.component.ts | 38 ++-- .../echo-toggle/echo-toggle.stories.ts | 4 + frontend/src/app/models/organization.ts | 10 + frontend/src/app/models/section.ts | 2 + .../pages/landing/landing.component.spec.ts | 19 ++ .../src/app/pages/lesson-view.component.html | 2 +- .../src/app/pages/login/login.component.html | 8 +- .../src/app/pages/login/login.component.ts | 6 + .../onboarding-organization.component.html | 88 +++++---- .../onboarding-organization.component.scss | 59 +++--- .../organization-courses.component.html | 21 ++ .../organization-courses.component.scss | 1 + .../organization-courses.component.spec.ts | 27 +++ .../organization-courses.component.ts | 55 ++++++ .../organization-invitations.component.html | 31 +++ .../organization-invitations.component.scss | 1 + ...organization-invitations.component.spec.ts | 50 +++++ .../organization-invitations.component.ts | 106 +++++++++++ .../organization-overview.component.html | 62 ++++++ .../organization-overview.component.scss | 1 + .../organization-overview.component.spec.ts | 54 ++++++ .../organization-overview.component.ts | 120 ++++++++++++ .../organization-people.component.html | 60 ++++++ .../organization-people.component.scss | 1 + .../organization-people.component.spec.ts | 41 ++++ .../organization-people.component.ts | 86 +++++++++ ...organization-section-detail.component.html | 17 ++ ...organization-section-detail.component.scss | 1 + .../organization-section-detail.component.ts | 77 ++++++++ .../organization-sections.component.html | 22 +++ .../organization-sections.component.scss | 1 + .../organization-sections.component.spec.ts | 29 +++ .../organization-sections.component.ts | 55 ++++++ .../organization-settings.component.html | 15 ++ .../organization-settings.component.scss | 1 + .../organization-settings.component.spec.ts | 41 ++++ .../organization-settings.component.ts | 68 +++++++ .../public-product-detail.component.spec.ts | 21 ++ .../public-product-detail.component.ts | 35 +++- .../public-products.component.spec.ts | 27 +++ .../public-products.component.ts | 30 ++- .../registration/registration.component.html | 24 +-- .../registration/registration.component.ts | 13 +- .../app/services/organization.service.spec.ts | 14 ++ .../src/app/services/organization.service.ts | 11 +- .../services/shell-navigation.service.spec.ts | 17 +- .../app/services/shell-navigation.service.ts | 19 +- frontend/src/styles.scss | 72 ++++++- .../src/styles/_organization-production.scss | 56 ++++++ frontend/src/styles/_typography.scss | 19 +- .../tests/demo/critical-accessibility.spec.ts | 98 ++++++++++ .../demo/organization-admin-smoke.spec.ts | 82 ++++++++ frontend/tests/demo/visual-regression.spec.ts | 57 ++++++ .../admin-users-desktop-chromium-win32.png | Bin 0 -> 112196 bytes .../login-mobile-chromium-win32.png | Bin 0 -> 157865 bytes .../public-landing-desktop-chromium-win32.png | Bin 0 -> 229279 bytes .../public-landing-mobile-chromium-win32.png | Bin 0 -> 85281 bytes .../student-lesson-mobile-chromium-win32.png | Bin 0 -> 64654 bytes ...teacher-classes-desktop-chromium-win32.png | Bin 0 -> 121309 bytes .../tasks.md | 22 +-- 112 files changed, 3056 insertions(+), 251 deletions(-) delete mode 100644 .pytest_tmp/basetemp/test_upload_badge_image0/badges/3b09bf0f-a596-4cd5-854c-4042ef6e39e0.png delete mode 120000 .pytest_tmp/basetemp/test_upload_badge_imagecurrent delete mode 100644 .pytest_tmp/basetemp/test_upload_storybook_page0/storybook/f170f98c-d8e4-4078-937f-b6d6c62f0cec.png delete mode 120000 .pytest_tmp/basetemp/test_upload_storybook_pagecurrent create mode 100644 backend/app/section_scope.py create mode 100644 backend/tests/test_org_admin_scope.py create mode 100644 docs/ux-overhaul/org-accessibility-review.md create mode 100644 docs/ux-overhaul/org-data-contract.md create mode 100644 docs/ux-overhaul/org-privacy-review.md create mode 100644 docs/ux-overhaul/org-production-audit.md create mode 100644 docs/ux-overhaul/org-production-implementation.md create mode 100644 docs/ux-overhaul/org-responsive-review.md create mode 100644 docs/ux-overhaul/org-role-capability-map.md create mode 100644 docs/ux-overhaul/production-accessibility-visual-review.md create mode 100644 frontend/src/app/components/echo-controls.spec.ts create mode 100644 frontend/src/app/components/echo-data-list/echo-data-list.component.spec.ts create mode 100644 frontend/src/app/components/echo-data-list/echo-data-list.component.ts create mode 100644 frontend/src/app/components/echo-data-list/echo-data-list.stories.ts create mode 100644 frontend/src/app/components/echo-search/echo-search.component.ts create mode 100644 frontend/src/app/components/echo-search/echo-search.stories.ts create mode 100644 frontend/src/app/pages/landing/landing.component.spec.ts create mode 100644 frontend/src/app/pages/organization-courses/organization-courses.component.html create mode 100644 frontend/src/app/pages/organization-courses/organization-courses.component.scss create mode 100644 frontend/src/app/pages/organization-courses/organization-courses.component.spec.ts create mode 100644 frontend/src/app/pages/organization-courses/organization-courses.component.ts create mode 100644 frontend/src/app/pages/organization-invitations/organization-invitations.component.html create mode 100644 frontend/src/app/pages/organization-invitations/organization-invitations.component.scss create mode 100644 frontend/src/app/pages/organization-invitations/organization-invitations.component.spec.ts create mode 100644 frontend/src/app/pages/organization-invitations/organization-invitations.component.ts create mode 100644 frontend/src/app/pages/organization-overview/organization-overview.component.html create mode 100644 frontend/src/app/pages/organization-overview/organization-overview.component.scss create mode 100644 frontend/src/app/pages/organization-overview/organization-overview.component.spec.ts create mode 100644 frontend/src/app/pages/organization-overview/organization-overview.component.ts create mode 100644 frontend/src/app/pages/organization-people/organization-people.component.html create mode 100644 frontend/src/app/pages/organization-people/organization-people.component.scss create mode 100644 frontend/src/app/pages/organization-people/organization-people.component.spec.ts create mode 100644 frontend/src/app/pages/organization-people/organization-people.component.ts create mode 100644 frontend/src/app/pages/organization-section-detail/organization-section-detail.component.html create mode 100644 frontend/src/app/pages/organization-section-detail/organization-section-detail.component.scss create mode 100644 frontend/src/app/pages/organization-section-detail/organization-section-detail.component.ts create mode 100644 frontend/src/app/pages/organization-sections/organization-sections.component.html create mode 100644 frontend/src/app/pages/organization-sections/organization-sections.component.scss create mode 100644 frontend/src/app/pages/organization-sections/organization-sections.component.spec.ts create mode 100644 frontend/src/app/pages/organization-sections/organization-sections.component.ts create mode 100644 frontend/src/app/pages/organization-settings/organization-settings.component.html create mode 100644 frontend/src/app/pages/organization-settings/organization-settings.component.scss create mode 100644 frontend/src/app/pages/organization-settings/organization-settings.component.spec.ts create mode 100644 frontend/src/app/pages/organization-settings/organization-settings.component.ts create mode 100644 frontend/src/app/pages/public-products/public-product-detail.component.spec.ts create mode 100644 frontend/src/app/pages/public-products/public-products.component.spec.ts create mode 100644 frontend/src/styles/_organization-production.scss create mode 100644 frontend/tests/demo/critical-accessibility.spec.ts create mode 100644 frontend/tests/demo/organization-admin-smoke.spec.ts create mode 100644 frontend/tests/demo/visual-regression.spec.ts create mode 100644 frontend/tests/demo/visual-regression.spec.ts-snapshots/admin-users-desktop-chromium-win32.png create mode 100644 frontend/tests/demo/visual-regression.spec.ts-snapshots/login-mobile-chromium-win32.png create mode 100644 frontend/tests/demo/visual-regression.spec.ts-snapshots/public-landing-desktop-chromium-win32.png create mode 100644 frontend/tests/demo/visual-regression.spec.ts-snapshots/public-landing-mobile-chromium-win32.png create mode 100644 frontend/tests/demo/visual-regression.spec.ts-snapshots/student-lesson-mobile-chromium-win32.png create mode 100644 frontend/tests/demo/visual-regression.spec.ts-snapshots/teacher-classes-desktop-chromium-win32.png diff --git a/.gitignore b/.gitignore index 994f2f3..5319d85 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ Thumbs.db frontend/test-results/ frontend/playwright-report/ backend/.pytest_tmp/ + +.pytest_tmp/ +.pytest_cache/ diff --git a/.pytest_tmp/basetemp/test_upload_badge_image0/badges/3b09bf0f-a596-4cd5-854c-4042ef6e39e0.png b/.pytest_tmp/basetemp/test_upload_badge_image0/badges/3b09bf0f-a596-4cd5-854c-4042ef6e39e0.png deleted file mode 100644 index 0dd1608..0000000 --- a/.pytest_tmp/basetemp/test_upload_badge_image0/badges/3b09bf0f-a596-4cd5-854c-4042ef6e39e0.png +++ /dev/null @@ -1,2 +0,0 @@ -‰PNG - diff --git a/.pytest_tmp/basetemp/test_upload_badge_imagecurrent b/.pytest_tmp/basetemp/test_upload_badge_imagecurrent deleted file mode 120000 index 908ec8f..0000000 --- a/.pytest_tmp/basetemp/test_upload_badge_imagecurrent +++ /dev/null @@ -1 +0,0 @@ -C:/Users/aquin/source/repos/echoed/.pytest_tmp/basetemp/test_upload_badge_image0 \ No newline at end of file diff --git a/.pytest_tmp/basetemp/test_upload_storybook_page0/storybook/f170f98c-d8e4-4078-937f-b6d6c62f0cec.png b/.pytest_tmp/basetemp/test_upload_storybook_page0/storybook/f170f98c-d8e4-4078-937f-b6d6c62f0cec.png deleted file mode 100644 index 0dd1608..0000000 --- a/.pytest_tmp/basetemp/test_upload_storybook_page0/storybook/f170f98c-d8e4-4078-937f-b6d6c62f0cec.png +++ /dev/null @@ -1,2 +0,0 @@ -‰PNG - diff --git a/.pytest_tmp/basetemp/test_upload_storybook_pagecurrent b/.pytest_tmp/basetemp/test_upload_storybook_pagecurrent deleted file mode 120000 index d1032a2..0000000 --- a/.pytest_tmp/basetemp/test_upload_storybook_pagecurrent +++ /dev/null @@ -1 +0,0 @@ -C:/Users/aquin/source/repos/echoed/.pytest_tmp/basetemp/test_upload_storybook_page0 \ No newline at end of file diff --git a/backend/app/api/routes/analytics.py b/backend/app/api/routes/analytics.py index de237a9..42facf6 100644 --- a/backend/app/api/routes/analytics.py +++ b/backend/app/api/routes/analytics.py @@ -6,6 +6,7 @@ from app.crud.badges import BADGE_RULES, calculate_streak_days, get_or_create_badge from app.database import get_db from app.deps import require_roles, require_org_roles +from app.section_scope import require_scoped_section from app.enum import MembershipStatus, ProgressStatus from app.models import ( AccessGrant, @@ -1045,15 +1046,16 @@ def get_section_summary( db: Session = Depends(get_db), membership=Depends(require_org_roles("teacher", "org_admin", "instructor")), ): + section = require_scoped_section(db, membership, section_id) total_enrolled = ( db.query(Enrollment) - .filter(Enrollment.section_id == section_id) + .filter(Enrollment.section_id == section.id) .count() ) completed_lessons = ( db.query(SegmentProgress) .filter( - SegmentProgress.section_id == section_id, + SegmentProgress.section_id == section.id, SegmentProgress.status == ProgressStatus.COMPLETED, ) .count() @@ -1061,13 +1063,13 @@ def get_section_summary( completed_units = ( db.query(StudentUnitProgress) .filter( - StudentUnitProgress.section_id == section_id, + StudentUnitProgress.section_id == section.id, StudentUnitProgress.status == ProgressStatus.COMPLETED, ) .count() ) return { - "section_id": section_id, + "section_id": str(section.id), "totals": { "enrolled": total_enrolled, "lessons_completed": completed_lessons, diff --git a/backend/app/api/routes/assignments.py b/backend/app/api/routes/assignments.py index 754b76f..c7a034a 100644 --- a/backend/app/api/routes/assignments.py +++ b/backend/app/api/routes/assignments.py @@ -5,6 +5,7 @@ from app.database import get_db from app.deps import get_current_user, require_org_roles from app.models import Assignment, AssignmentSubmission +from app.section_scope import require_scoped_section from app.enum import AssignmentTargetType, AssignmentSubmissionStatus from app.schemas import ( AssignmentCreateRequest, @@ -24,8 +25,9 @@ def create_assignment( current_user=Depends(get_current_user), membership=Depends(require_org_roles("teacher", "org_admin", "instructor")), ): + section = require_scoped_section(db, membership, section_id) assignment = Assignment( - section_id=section_id, + section_id=section.id, target_type=AssignmentTargetType(payload.target_type), target_id=payload.target_id, due_at=payload.due_at, @@ -42,9 +44,10 @@ def create_assignment( def list_assignments( section_id: str, db: Session = Depends(get_db), - current_user=Depends(get_current_user), + membership=Depends(require_org_roles("teacher", "org_admin", "instructor")), ): - return db.query(Assignment).filter(Assignment.section_id == section_id).all() + section = require_scoped_section(db, membership, section_id) + return db.query(Assignment).filter(Assignment.section_id == section.id).all() @router.post("/assignments/{assignment_id}/submit", response_model=AssignmentSubmissionResponse) diff --git a/backend/app/api/routes/orgs.py b/backend/app/api/routes/orgs.py index 6fbbccc..b2e65bb 100644 --- a/backend/app/api/routes/orgs.py +++ b/backend/app/api/routes/orgs.py @@ -7,17 +7,30 @@ from app.database import get_db from app.deps import get_current_user from app.enum import OrganizationType, OrganizationRole -from app.models import Organization, OrganizationMembership +from app.models import Enrollment, Organization, OrganizationMembership, Section, User from app.schemas import ( OrganizationCreate, OrganizationResponse, + OrganizationMemberResponse, + OrganizationSectionResponse, OrganizationSwitchResponse, OrganizationUpdate, ) +from app.deps import require_org_roles router = APIRouter() +def _require_requested_organization(org_id: str, membership: OrganizationMembership) -> uuid.UUID: + try: + org_uuid = uuid.UUID(org_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid organization id") from exc + if membership.organization_id != org_uuid: + raise HTTPException(status_code=403, detail="Organization mismatch") + return org_uuid + + @router.get("/orgs", response_model=list[OrganizationResponse]) def list_orgs( db: Session = Depends(get_db), @@ -45,6 +58,67 @@ def list_orgs( ] +@router.get("/orgs/{org_id}/members", response_model=list[OrganizationMemberResponse]) +def list_org_members( + org_id: str, + db: Session = Depends(get_db), + membership=Depends(require_org_roles("org_admin")), +): + org_uuid = _require_requested_organization(org_id, membership) + rows = ( + db.query(OrganizationMembership, User) + .join(User, User.id == OrganizationMembership.user_id) + .filter(OrganizationMembership.organization_id == org_uuid) + .order_by(User.firstname.asc(), User.lastname.asc(), User.username.asc()) + .all() + ) + return [ + { + "id": row.id, + "user_id": user.id, + "display_name": f"{user.firstname or ''} {user.lastname or ''}".strip() or user.username, + "username": user.username, + "role": row.role.value, + "status": row.status.value, + "joined_at": row.created_at, + } + for row, user in rows + ] + + +@router.get("/orgs/{org_id}/sections", response_model=list[OrganizationSectionResponse]) +def list_org_sections( + org_id: str, + db: Session = Depends(get_db), + membership=Depends(require_org_roles("org_admin")), +): + org_uuid = _require_requested_organization(org_id, membership) + sections = ( + db.query(Section) + .filter(Section.organization_id == org_uuid) + .order_by(Section.name.asc(), Section.created_at.asc()) + .all() + ) + results = [] + for section in sections: + enrollments = db.query(Enrollment).filter(Enrollment.section_id == section.id).all() + results.append( + { + "id": section.id, + "organization_id": section.organization_id, + "course_version_id": section.course_version_id, + "name": section.name, + "mode": section.mode.value, + "start_date": section.start_date, + "end_date": section.end_date, + "created_by": section.created_by, + "learner_count": sum(1 for row in enrollments if row.role_in_section == "student"), + "teacher_count": sum(1 for row in enrollments if row.role_in_section in {"teacher", "instructor"}), + } + ) + return results + + @router.post("/orgs", response_model=OrganizationResponse) def create_org( payload: OrganizationCreate, diff --git a/backend/app/api/routes/sections.py b/backend/app/api/routes/sections.py index 27af817..e0ea910 100644 --- a/backend/app/api/routes/sections.py +++ b/backend/app/api/routes/sections.py @@ -3,9 +3,10 @@ from app.database import get_db from app.deps import get_current_user, require_org_roles -from app.models import Section, Enrollment, User +from app.models import Section, Enrollment, OrganizationMembership, User from app.enum import SectionMode from app.schemas import SectionCreateRequest, SectionResponse, EnrollmentCreateRequest, EnrollmentResponse +from app.section_scope import require_scoped_section router = APIRouter() @@ -56,7 +57,8 @@ def section_roster( db: Session = Depends(get_db), membership=Depends(require_org_roles("teacher", "org_admin", "instructor")), ): - return db.query(Enrollment).filter(Enrollment.section_id == section_id).all() + section = require_scoped_section(db, membership, section_id) + return db.query(Enrollment).filter(Enrollment.section_id == section.id).all() @router.post("/sections/{section_id}/enrollments", response_model=EnrollmentResponse) @@ -66,6 +68,7 @@ def add_enrollment( db: Session = Depends(get_db), membership=Depends(require_org_roles("teacher", "org_admin", "instructor")), ): + section = require_scoped_section(db, membership, section_id) if not payload.user_id and not payload.email: raise HTTPException(status_code=400, detail="Provide a user_id or email") @@ -78,7 +81,19 @@ def add_enrollment( if not user: raise HTTPException(status_code=404, detail="User not found") - enrollment = Enrollment(section_id=section_id, user_id=user.id) + active_membership = ( + db.query(OrganizationMembership) + .filter( + OrganizationMembership.organization_id == membership.organization_id, + OrganizationMembership.user_id == user.id, + OrganizationMembership.status == "active", + ) + .first() + ) + if active_membership is None: + raise HTTPException(status_code=400, detail="User is not an active member of this organization") + + enrollment = Enrollment(section_id=section.id, user_id=user.id) db.add(enrollment) db.commit() db.refresh(enrollment) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 06adb1f..105b37c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -87,6 +87,16 @@ class Config: from_attributes = True +class OrganizationMemberResponse(BaseModel): + id: UUID + user_id: UUID + display_name: str + username: str + role: str + status: str + joined_at: datetime + + class WorkspaceResponse(BaseModel): id: UUID organization_id: Optional[UUID] = None @@ -506,6 +516,11 @@ class Config: from_attributes = True +class OrganizationSectionResponse(SectionResponse): + learner_count: int = 0 + teacher_count: int = 0 + + class EnrollmentCreateRequest(BaseModel): email: Optional[str] = None user_id: Optional[UUID] = None diff --git a/backend/app/section_scope.py b/backend/app/section_scope.py new file mode 100644 index 0000000..1f50701 --- /dev/null +++ b/backend/app/section_scope.py @@ -0,0 +1,29 @@ +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models import OrganizationMembership, Section + + +def require_scoped_section( + db: Session, + membership: OrganizationMembership, + section_id: str | UUID, +) -> Section: + try: + normalized_id = section_id if isinstance(section_id, UUID) else UUID(section_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid section id") from exc + + section = ( + db.query(Section) + .filter( + Section.id == normalized_id, + Section.organization_id == membership.organization_id, + ) + .first() + ) + if section is None: + raise HTTPException(status_code=404, detail="Section not found") + return section diff --git a/backend/tests/test_org_admin_scope.py b/backend/tests/test_org_admin_scope.py new file mode 100644 index 0000000..2184ecf --- /dev/null +++ b/backend/tests/test_org_admin_scope.py @@ -0,0 +1,180 @@ +import uuid + +from fastapi.testclient import TestClient + +from app.auth import get_current_user +from app.database import get_db as database_get_db +from app.deps import get_db as dependency_get_db +from app.enum import CourseVersionStatus, OrganizationRole, OrganizationType +from app.main import app +from app.models import ( + Course, + CourseVersion, + Enrollment, + Organization, + OrganizationMembership, + Section, + User, +) + + +def _user(label: str, role: str) -> User: + suffix = uuid.uuid4() + return User( + id=uuid.uuid4(), + firstname=label, + lastname="Member", + username=f"{label.lower()}-{suffix}", + email=f"{label.lower()}-{suffix}@example.com", + hashed_password="test", + role=role, + ) + + +def _client(db_session, current_user: User) -> TestClient: + def override_db(): + yield db_session + + app.dependency_overrides[database_get_db] = override_db + app.dependency_overrides[dependency_get_db] = override_db + app.dependency_overrides[get_current_user] = lambda: current_user + return TestClient(app) + + +def _organization(db_session, name: str) -> Organization: + organization = Organization(id=uuid.uuid4(), name=name, type=OrganizationType.SCHOOL) + db_session.add(organization) + db_session.flush() + return organization + + +def _membership(db_session, organization: Organization, user: User, role: OrganizationRole): + membership = OrganizationMembership( + id=uuid.uuid4(), + organization_id=organization.id, + user_id=user.id, + role=role, + ) + db_session.add(membership) + return membership + + +def _section(db_session, organization: Organization, creator: User, name: str) -> Section: + course = Course( + id=uuid.uuid4(), + organization_id=organization.id, + created_by=creator.id, + title=f"{name} Course", + description="Scoped course", + ) + db_session.add(course) + db_session.flush() + version = CourseVersion( + id=uuid.uuid4(), + course_id=course.id, + version_number=1, + status=CourseVersionStatus.PUBLISHED, + ) + db_session.add(version) + db_session.flush() + section = Section( + id=uuid.uuid4(), + organization_id=organization.id, + course_version_id=version.id, + name=name, + mode="remote", + created_by=creator.id, + ) + db_session.add(section) + db_session.flush() + return section + + +def test_org_admin_member_directory_is_minimal_and_scoped(db_session): + admin = _user("Admin", "org_admin") + teacher = _user("Teacher", "teacher") + outsider = _user("Outsider", "student") + own_org = _organization(db_session, "Own School") + other_org = _organization(db_session, "Other School") + db_session.add_all([admin, teacher, outsider]) + db_session.flush() + _membership(db_session, own_org, admin, OrganizationRole.ORG_ADMIN) + _membership(db_session, own_org, teacher, OrganizationRole.TEACHER) + _membership(db_session, other_org, outsider, OrganizationRole.STUDENT) + db_session.commit() + + client = _client(db_session, admin) + response = client.get(f"/api/orgs/{own_org.id}/members", headers={"X-Org-Id": str(own_org.id)}) + + assert response.status_code == 200 + assert {row["display_name"] for row in response.json()} == {"Admin Member", "Teacher Member"} + assert all("email" not in row for row in response.json()) + assert all("hashed_password" not in row for row in response.json()) + + mismatch = client.get(f"/api/orgs/{other_org.id}/members", headers={"X-Org-Id": str(own_org.id)}) + assert mismatch.status_code == 403 + app.dependency_overrides.clear() + + +def test_org_admin_section_list_includes_only_active_organization(db_session): + admin = _user("Admin", "org_admin") + learner = _user("Learner", "student") + own_org = _organization(db_session, "Own School") + other_org = _organization(db_session, "Other School") + db_session.add_all([admin, learner]) + db_session.flush() + _membership(db_session, own_org, admin, OrganizationRole.ORG_ADMIN) + _membership(db_session, own_org, learner, OrganizationRole.STUDENT) + own_section = _section(db_session, own_org, admin, "Own Class") + _section(db_session, other_org, admin, "Other Class") + db_session.add(Enrollment(section_id=own_section.id, user_id=learner.id, role_in_section="student")) + db_session.commit() + + client = _client(db_session, admin) + response = client.get(f"/api/orgs/{own_org.id}/sections", headers={"X-Org-Id": str(own_org.id)}) + + assert response.status_code == 200 + assert [row["name"] for row in response.json()] == ["Own Class"] + assert response.json()[0]["learner_count"] == 1 + app.dependency_overrides.clear() + + +def test_nested_section_reads_reject_cross_organization_ids(db_session): + admin = _user("Admin", "org_admin") + own_org = _organization(db_session, "Own School") + other_org = _organization(db_session, "Other School") + db_session.add(admin) + db_session.flush() + _membership(db_session, own_org, admin, OrganizationRole.ORG_ADMIN) + other_section = _section(db_session, other_org, admin, "Other Class") + db_session.commit() + + client = _client(db_session, admin) + headers = {"X-Org-Id": str(own_org.id)} + + assert client.get(f"/api/sections/{other_section.id}/roster", headers=headers).status_code == 404 + assert client.get(f"/api/sections/{other_section.id}/assignments", headers=headers).status_code == 404 + assert client.get(f"/api/sections/{other_section.id}/analytics/summary", headers=headers).status_code == 404 + app.dependency_overrides.clear() + + +def test_section_enrollment_requires_active_organization_member(db_session): + admin = _user("Admin", "org_admin") + outsider = _user("Outsider", "student") + own_org = _organization(db_session, "Own School") + db_session.add_all([admin, outsider]) + db_session.flush() + _membership(db_session, own_org, admin, OrganizationRole.ORG_ADMIN) + section = _section(db_session, own_org, admin, "Own Class") + db_session.commit() + + client = _client(db_session, admin) + response = client.post( + f"/api/sections/{section.id}/enrollments", + headers={"X-Org-Id": str(own_org.id)}, + json={"user_id": str(outsider.id)}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "User is not an active member of this organization" + app.dependency_overrides.clear() diff --git a/docs/ux-overhaul/backend-gap-register.md b/docs/ux-overhaul/backend-gap-register.md index 4d6068d..f98edd0 100644 --- a/docs/ux-overhaul/backend-gap-register.md +++ b/docs/ux-overhaul/backend-gap-register.md @@ -24,3 +24,11 @@ Date: 2026-07-13 | Content admin | Review with accountability | Confirmed wrapper state changes; no comments/history claims | No reviewer assignment, comments, decision rationale, role separation, or audit events | Approval cannot be attributed or reconstructed | Partial | Content review assignments, rationale, and audit events | High | | Content admin | Safely unpublish or roll back | Public publishing only; no unpublish control | Product publish has no unpublish/rollback/dependency validation endpoint | Public visibility may not be reversibly corrected through Studio | Yes for emergency correction | Reversible publishing lifecycle with dependency validation | Critical | | Content admin | Collaborate and recover versions | No collaborative/version-history UI | No content ownership, locking, autosave, merge, or broadly usable version-history contract | Lost updates and unclear accountability | Yes for collaboration, not current wrapper work | Content ownership and version-history proposal | Medium | +| Organization admin | Remove members or change organization roles | Read-only minimized member directory | No scoped membership mutation or final-org-admin invariant | Unsafe role changes can cause cross-org access or organization lockout | Yes | Scoped organization membership lifecycle | High | +| Organization admin | Resend or cancel invitations | Shows status and create only | No resend/revoke endpoints or explicit lifecycle events | Stale invitations and acceptance secrets need enforceable revocation | Partial | Organization invitation lifecycle | High | +| Organization admin | Safely change course availability | Read-only availability view | Access-grant create accepts an arbitrary platform user UUID without active-org membership validation | Could grant access across organization boundaries | Yes | Member-safe organization learning access | Critical | +| Organization admin | Manage branding, contacts, and time zone | Name-only settings | No branding, contact, or time-zone update contract | Identity assets and contacts require rights/privacy controls | Partial | Organization profile and branding settings | Medium | +| Organization admin | Manage district hierarchy and licensing | No UI | No district, license, billing, or subscription models | Invented hierarchy could broaden scope; financial data requires new controls | No current workflow | District and licensing domain proposal | Low | +| Organization admin | Use advanced scoped reporting | Defined operational counts only | No organization engagement, progress aggregate, or metric-definition API | Ad hoc aggregation may expose learner data or mislead administrators | Partial | Privacy-preserving organization reporting | Medium | +| Organization admin | Review activity and publish announcements | No UI | No organization audit events or announcement model | High-impact actions lack accountability; announcements need audience scoping | No current workflow | Organization audit and communications contracts | Medium | +| Organization admin | Bulk import or export rosters | No UI | No validated import, export, job status, or privacy-safe file contract | Bulk files increase learner-data disclosure and integrity risk | Yes for bulk operations | Governed organization roster transfer | Medium | diff --git a/docs/ux-overhaul/org-accessibility-review.md b/docs/ux-overhaul/org-accessibility-review.md new file mode 100644 index 0000000..5b2a134 --- /dev/null +++ b/docs/ux-overhaul/org-accessibility-review.md @@ -0,0 +1,11 @@ +# Organization Administration Accessibility Review + +Date: 2026-07-14 + +The canonical Organization pages use one page heading, semantic section headings, labeled search/filter/form controls, text status labels, shared loading/error/empty states, and descriptive links whose accessible names include the affected class or destination. Result counts and save outcomes use live status regions. + +Invitation privilege changes use the shared confirmation dialog with explicit object and consequence wording, focus trapping, Escape/cancel behavior, and focus restoration. The UI does not rely on hover-only controls or color alone. Inputs and actions retain visible focus and minimum touch-target sizing. + +Member, invitation, class, and offering collections use responsive semantic records rather than horizontally scrolling desktop tables. Long names, usernames, and invitation addresses wrap. The mobile navigation and reduced-motion behavior remain provided by the shared shell. + +Automated component and Playwright coverage verifies labels, keyboard entry through primary navigation and filters, confirmation cancellation, permission handling, and the 390px layout. A full assistive-technology audit remains part of the cross-product accessibility hardening task. diff --git a/docs/ux-overhaul/org-data-contract.md b/docs/ux-overhaul/org-data-contract.md new file mode 100644 index 0000000..3a5cb8c --- /dev/null +++ b/docs/ux-overhaul/org-data-contract.md @@ -0,0 +1,34 @@ +# Organization Administration Data Contract + +Date: 2026-07-14 + +## Organization + +`GET /api/orgs` supplies the organizations available to the current account. The canonical experience uses the active organization ID from the authenticated organization context. Visible fields are name, type, country, time zone, creation date, and the current account's organization role. Only `name` is writable through `PATCH /api/orgs/{org_id}`. + +## Members + +`GET /api/orgs/{org_id}/members` is an organization-admin-only, requested-organization-scoped read. It returns membership ID, user ID for routing/internal correlation, display name, username, organization role, membership status, and join date. It intentionally omits email, password hashes, authentication details, tokens, profile data, and memberships in other organizations. + +Teacher views filter organization roles `teacher` and `instructor`; student views filter `student`. Counts and filters operate on this scoped response. + +## Invitations + +Existing invite reads provide email, requested role, expiration, acceptance timestamp, and creation context. The UI derives only `Pending`, `Accepted`, or `Expired`. Acceptance tokens are never rendered. Existing writes create an invite; the UI does not imply resend or cancellation support. + +## Classes + +`GET /api/orgs/{org_id}/sections` returns every class in the active organization with name, course-version reference, mode, dates, creator, and aggregate learner/teacher counts. Class detail uses the existing roster and section summary only after backend ownership validation. Roster presentation is minimized to organization member identity, role, status, and enrollment role. + +## Learning availability + +V2 product reads provide workspace-scoped course, learning-path, and certification wrappers. Access-grant reads support aggregate availability counts. The Organization UI does not expose Studio fields or mutation controls and does not claim this is a complete global course catalog. + +## Reporting definitions + +- Members, teachers, and students: active-organization membership records matching the displayed filters. +- Pending invitations: unaccepted invites whose expiration is in the future. +- Active classes: organization classes whose available date range includes the current time, or whose missing bounds do not exclude the current time. +- Available learning offerings: scoped course, learning-path, and certification product wrappers returned by current APIs. + +These are operational counts, not engagement, performance, growth, or comparative analytics. diff --git a/docs/ux-overhaul/org-privacy-review.md b/docs/ux-overhaul/org-privacy-review.md new file mode 100644 index 0000000..90ba54a --- /dev/null +++ b/docs/ux-overhaul/org-privacy-review.md @@ -0,0 +1,21 @@ +# Organization Administration Privacy Review + +Date: 2026-07-14 + +## High-risk surfaces and mitigations + +| Surface | Risk | Mitigation | +| --- | --- | --- | +| Member directory | Broad account or contact disclosure | Dedicated organization-scoped response; no email, security fields, or unrelated memberships | +| Invitation list | Secret acceptance token or unnecessary contact exposure | Tokens omitted; email shown only for organization invitation administration | +| Class deep links | Cross-organization roster or progress disclosure | Backend filters section ID and active organization together; unknown/out-of-scope IDs return not found | +| Enrollment mutation | Adding an unrelated platform user | Existing endpoint now requires an active membership in the same organization | +| Assignment and analytics reads | Cross-organization identifier probing | Shared scoped-section dependency applied before read or write | +| Course availability | Unsafe access grants to arbitrary users | Canonical view is read-only; no grant mutation control | +| Errors and logs | Sensitive response payloads | UI uses general recovery copy and does not render raw backend bodies or tokens | + +## Authorization conclusions + +Canonical deep links use `HomeSessionGuard` and `RoleGuard`, but the API remains authoritative. Organization IDs are checked against the active membership, and nested section APIs verify the requested section belongs to that organization. The UI never uses platform-wide user endpoints for organization member discovery. + +Remaining privacy work includes a member-safe access-grant mutation contract, organization-scoped audit events, and explicit retention/lifecycle behavior for invitations. Those gaps are recorded separately. diff --git a/docs/ux-overhaul/org-production-audit.md b/docs/ux-overhaul/org-production-audit.md new file mode 100644 index 0000000..effb29d --- /dev/null +++ b/docs/ux-overhaul/org-production-audit.md @@ -0,0 +1,81 @@ +# Organization Administration Production Audit + +Date: 2026-07-14 + +## Scope and role model + +The Organization product area belongs to an authenticated user whose active organization membership role is `org_admin`. Platform role alone is not sufficient: canonical routes continue to use `RoleGuard`, and every organization API must validate the active membership and requested organization identifier. Organization administrators manage one active organization context at a time and do not receive platform-global Admin or Studio controls. + +The existing `PermissionsService` combines the token role, active organization role, organization bootstrap, and navigation permissions. The canonical implementation must continue to use that service rather than introducing a second role resolver. + +## Route and screen audit + +| Current route or surface | User goal | Current roles | APIs | Sensitivity | Current capability | Backend authorization | Current risks | Migration recommendation | Product area | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `/workspace/learners` | Enter organization/learner operations | Broad creator roles | None directly | Medium | Generic role selector, not an organization dashboard | Frontend guard only for the page | Mixed role language and no organization summary | Canonical `/organization`; retain legacy route | Organization | +| `/workspace/settings/invites` | Invite organization members | `org_admin`, `content_admin` in frontend | `GET/POST /api/orgs/{org_id}/invites` | High: email, role, invite token | Create and list | `require_org_roles("org_admin")` plus requested-org match | Frontend exposes invite tokens; content-admin route guard is broader than backend; no loading/error state; unsupported parent role offered | Canonical `/organization/invitations` for `org_admin`; never render tokens; retain legacy route | Organization | +| `/workspace/learners/cohorts` | List and create classes | teacher/instructor/org-admin | `GET/POST /api/sections`, section summary | High: class and learner activity | List returns only sections created by or enrolling current user; create requires raw course-version UUID | Create uses active membership; list is current-user scoped, not organization scoped | Organization admin cannot see the organization; raw UUID input; partial summaries; misleading completeness | Canonical `/organization/sections` over an organization-scoped read contract; retain legacy teacher view | Organization/Teach | +| `/workspace/learners/cohorts/:id` | Inspect class roster and assignments | teacher/instructor/org-admin | roster, assignments, section summary, users/students, courses | Very high: learners and progress | Existing teacher-oriented detail | Roster and summary require an allowed active-org role but do not verify section organization; users/students rejects org-admin | Cross-organization ID probing can disclose enrollment/progress aggregates; calls occur before list membership is confirmed | New organization detail must resolve section through a scoped list before nested reads; backend scope fix required | Organization/Teach | +| `/home/sections` and `/:id` | Teacher class workflow | teacher/instructor/org-admin | Same section APIs | Very high | Teacher authoring/assignment experience | Mixed platform and org-role checks | Organization responsibilities are mixed with teacher assignment work | Keep as Teach/legacy; Organization uses separate oversight pages | Teach | +| `/workspace/access` | Manage product access grants | Broad creator roles | V2 products and access grants | High: learner IDs and access | Create/revoke with raw user UUID | Workspace visibility scope; creator-role checks | No organization member validation for grant target; raw IDs; no confirmation in legacy page | Canonical Organization course availability is read-only until member-safe grant targeting exists | Studio/Organization boundary | +| `/workspace/settings` | Personal preferences and organization name | Broad creator roles | preferences; `GET/PATCH /api/orgs` | Medium | Personal settings plus name update | Update permits active org-admin/content-admin or super-admin | Mixed personal and organization settings; errors only via toast | Canonical `/organization/settings` exposes active organization identity and name only; personal preferences remain separate | Organization/Shared | +| `/workspace/analytics` | Operational wrapper analytics | Broad creator roles | V2 analytics | High aggregate | Workspace/content-product metrics | Membership-derived workspace scope | Content-production metrics do not answer organization administration questions | Do not migrate into Organization reporting; compose only defined organization counts | Studio | +| `/workspace/products` | View V2 product wrappers | Broad creator roles | `GET /api/products` | Medium | Workspace-scoped content wrappers | Membership-derived workspace scope | Includes non-course products and editorial states | Canonical `/organization/courses` filters course/program wrappers and labels limitations | Organization/Studio boundary | +| Admin organization pages | Platform oversight | admin/super-admin | `/api/orgs` | High | Current-account membership visibility only | Platform route guard | Not an org self-service experience | Remain in Admin; do not reuse as Organization | Admin | + +## Backend capability audit + +### Organizations + +- `GET /api/orgs` returns only organizations in which the current user has a membership. Fields are ID, name, type, country, timezone, created date, and the current user's organization role. +- `PATCH /api/orgs/{org_id}` supports only `name`. It verifies current-user membership with `org_admin` or `content_admin`, or platform `super_admin`. +- `POST /api/orgs/{org_id}/switch` verifies membership and refreshes the active organization token context. +- Branding, contacts, announcements, lifecycle state, archival, billing, licensing, and district hierarchy do not exist. + +### Members, teachers, and students + +- `OrganizationMembership` stores organization, user, organization role, status, and creation time. +- No organization-scoped membership read endpoint exists. +- `/api/users` is platform-admin-only and `/api/users/students` is admin/teacher-only; neither is safe or authorized for Organization. +- No member removal, role-change, disable, bulk import, or export endpoint exists. +- A minimal organization-scoped member read endpoint is required to make existing membership data visible without exposing platform-global users. + +### Invitations + +- Organization admins can list and create invites for the active organization. +- The model exposes email, role, expiration, accepted timestamp, inviter, and a secret acceptance token. +- The canonical UI must omit the token and derive only `Pending`, `Accepted`, or `Expired` presentation from existing fields and current time. +- Resend and cancel APIs do not exist. They must not be represented as available actions. + +### Sections and classes + +- Create supports name, course-version ID, mode, and optional dates. +- The generic list endpoint is current-user scoped, not active-organization scoped. +- Roster and section summary endpoints authorize the caller's active organization role but fail to assert that the requested section belongs to that organization. +- Assignment routes share the existing teacher workflow and are outside this phase unless an organization-safe oversight contract is used. +- A scoped organization section list and section ownership validation are required before canonical detail pages can safely expose roster or summary data. + +### Course availability and access + +- `/api/courses` rejects `org_admin`. +- V2 products and access grants are membership/workspace scoped and include course/program wrapper relationships, visibility, review state, and access state. +- Product wrappers can support a read-only organization availability view. They are not equivalent to the full course catalog. +- Existing access-grant creation accepts any platform user UUID and does not verify organization membership, so the canonical Organization UI must not expose grant creation. + +### Reporting + +- Platform analytics reject `org_admin`; teacher analytics do not model Organization scope. +- V2 workspace analytics are scoped but describe content wrappers and access, not organization membership. +- Organization reporting may only compose counts from scoped members, invitations, sections, section enrollments, and available product wrappers. No engagement score, growth rate, or organization ranking is supported. + +## Accessibility and responsive audit + +- Legacy invite inputs use placeholders without programmatic labels and expose invite tokens. +- Legacy invite and access pages lack complete loading, failure, confirmation, and focus-restoration states. +- Legacy section creation requires users to paste a UUID and uses a desktop-oriented hierarchy. +- Existing class detail tabs and cards are teacher-oriented and call partially unsupported services for org-admin. +- Canonical pages require labeled controls, semantic responsive records, announced result counts, text statuses, 44px targets, dialog focus management, wrapping identifiers, and no horizontal tables at 390px. + +## Implementation decision + +Build a separate canonical `/organization/**` frontend family for organization oversight. Add only the narrowly required organization-scoped read contracts for members and sections, plus section ownership validation on existing nested reads. Reuse current invitation creation/listing, organization name update, V2 product reads, and shared state/confirmation infrastructure. Do not add invitation resend/cancel, member mutation, branding, billing, licensing, bulk operations, or invented analytics. diff --git a/docs/ux-overhaul/org-production-implementation.md b/docs/ux-overhaul/org-production-implementation.md new file mode 100644 index 0000000..8ed186c --- /dev/null +++ b/docs/ux-overhaul/org-production-implementation.md @@ -0,0 +1,36 @@ +# Organization Administration Production Implementation + +Date: 2026-07-14 + +## Delivered experience + +Phase 6 adds the guarded `/organization` product family for active `org_admin` memberships: + +- Overview with defined member, teacher, student, invitation, class, and available-learning counts plus supported attention states. +- Privacy-minimized member, teacher, and student lists with search and role/status filters. +- Invitation creation and status visibility, token omission, and confirmation for privileged organization roles. +- Organization-wide class list and scoped detail with roster and aggregate activity context. +- Read-only course, learning-path, and certification availability without Studio authoring or unsafe access mutation. +- Organization identity and name editing, with unsupported settings clearly separated. + +Legacy workspace and Teach routes remain available. Canonical navigation now sends Organization administrators to the new route family and removes Teacher-only items from their Organization navigation. + +## Backend changes + +No database migration or model lifecycle was added. Two narrow read contracts were added: organization members and organization sections. A shared scoped-section resolver now protects roster, enrollment, assignment, and section-summary endpoints, and enrollment targets must be active members of the same organization. + +## Components and reuse + +New page components cover overview, people modes, invitations, classes/detail, courses, and settings. They reuse the role-aware shell, `PermissionsService`, semantic tokens, shared state components, shared confirmation dialog, existing organization/V2 services, and responsive record conventions. No separate component system or frontend permission model was introduced. + +## Verification + +- Backend: all 228 tests passed; 226 passed in the full run and the two upload tests passed after clearing a stale test-temporary-directory ACL. +- Frontend unit suite: 284 tests passed in ChromeHeadless. +- Frontend production build: passed; the current 1.42 MB initial bundle exceeds the 1.25 MB warning budget by 170.89 kB. +- Playwright: all 16 seeded smoke, authorization, accessibility, and visual-regression tests passed in Chromium. +- OpenSpec: strict validation passed with all 33 tasks complete. + +## Limitations and deferred work + +Member mutations, invite resend/cancel, class editing, member-safe course access changes, organization branding/contact/time-zone settings, advanced reporting, audit logs, announcements, licensing, district hierarchy, and bulk import/export remain unsupported. The backend gap register captures these without presenting nonfunctional controls. diff --git a/docs/ux-overhaul/org-responsive-review.md b/docs/ux-overhaul/org-responsive-review.md new file mode 100644 index 0000000..ad32340 --- /dev/null +++ b/docs/ux-overhaul/org-responsive-review.md @@ -0,0 +1,9 @@ +# Organization Administration Responsive Review + +Date: 2026-07-14 + +Organization layouts use the shared shell plus responsive record, metric, form, and action grids. At 1440px and 1280px, overview metrics and operational lists use available horizontal space without turning the page into a chart dashboard. At 768px, filters and summaries collapse into stable stacked regions. At 390px, records, forms, action groups, and detail metadata become single-column and retain readable labels and 44px controls. + +Long organization names, usernames, invitation addresses, course titles, role labels, and class names wrap instead of widening the viewport. Class detail remains a separate page on mobile. The confirmation dialog uses the shared mobile treatment, and the settings form remains usable with a virtual keyboard. + +Playwright includes a 390px horizontal-overflow assertion and primary navigation checks. Build and static review cover the remaining target widths; screenshot baselines remain deferred to the cross-role visual-regression task. diff --git a/docs/ux-overhaul/org-role-capability-map.md b/docs/ux-overhaul/org-role-capability-map.md new file mode 100644 index 0000000..74baa00 --- /dev/null +++ b/docs/ux-overhaul/org-role-capability-map.md @@ -0,0 +1,30 @@ +# Organization Administrator Role Capability Map + +Date: 2026-07-14 + +## Effective role + +The Organization area is available only when the authenticated session has an active organization membership with role `org_admin`. The frontend `RoleGuard` uses the existing `PermissionsService`; API dependencies remain authoritative. A platform `admin`, `content_admin`, teacher, or student does not gain Organization access from the platform role name alone. + +## Supported capabilities + +| Capability | Access | Scope | API authority | +| --- | --- | --- | --- | +| View organization overview | Read | Active organization | Scoped member, invite, section, and product reads | +| View members, teachers, and students | Read | Active organization | `GET /api/orgs/{org_id}/members` | +| View invitations | Read | Active organization | `GET /api/orgs/{org_id}/invites` | +| Create invitation | Write | Active organization | `POST /api/orgs/{org_id}/invites` | +| View classes and class detail | Read | Active organization | Scoped organization sections, roster, and summary APIs | +| View available learning offerings | Read | Active organization workspace | V2 products and access-grant reads | +| Rename organization | Write | Active organization | `PATCH /api/orgs/{org_id}` | + +## Unsupported or read-only capabilities + +- Member removal, role changes, activation changes, and bulk roster operations have no verified API. +- Invitation resend and cancellation have no API. +- Canonical class pages are oversight-only; teacher assignment and class-authoring workflows remain in Teach. +- Course availability is read-only because access-grant creation does not validate that its target belongs to the active organization. +- Branding, time zone editing, contact details, announcements, licensing, and district settings have no write contract. +- Platform users, global organizations, Studio authoring, learner grading, and platform reports are outside this role. + +Organization membership and requested-object organization checks are enforced by the backend. Hidden navigation is never treated as authorization. diff --git a/docs/ux-overhaul/production-accessibility-visual-review.md b/docs/ux-overhaul/production-accessibility-visual-review.md new file mode 100644 index 0000000..9c8d4ef --- /dev/null +++ b/docs/ux-overhaul/production-accessibility-visual-review.md @@ -0,0 +1,29 @@ +# Production Accessibility and Visual Review + +Date: 2026-07-15 + +## Critical flows reviewed + +The automated keyboard and screen-reader-structure pass covers the public landing page, login, the governed student lesson, teacher class detail, and Admin user management. It verifies one visible page `h1`, expected landmarks and labels, visible keyboard focus, named interactive controls, and document bounds at 390, 768, 1280, and 1440 CSS pixels. + +The pass corrected two shared heading issues: the authenticated shell title is now supporting text instead of a second `h1`, and the lesson runtime owns a level-one page heading. Shared controls and state panels provide explicit labels, descriptions, live status or alert semantics, and 44px primary targets. + +## Visual regression set + +Playwright baselines cover: + +- Public landing at 1440px desktop and 390px mobile. +- Login at 390px mobile. +- Governed student lesson at 390px mobile. +- Teacher classes at desktop. +- Admin users at desktop. + +The baselines are stored next to `frontend/tests/demo/visual-regression.spec.ts`. The final non-updating browser run passed all 16 seeded smoke, authorization, accessibility, and visual-regression tests. + +## Responsive and clipping result + +All critical-flow checks pass without horizontal document overflow at 390, 768, 1280, and 1440. Visible controls have accessible names, status remains textual, and representative screens do not depend on color alone. The deprecated global typography utility no longer scales body text to 24px at desktop widths, removing sidebar label clipping; a duplicate global focus rule was removed while preserving the canonical tokenized focus treatment. + +## Remaining manual judgment + +Automated structure and image comparison complement but do not replace testing with users' configured screen readers, browser zoom, translated content, or OS-level high-contrast modes. Those should remain part of release acceptance on supported deployment environments. diff --git a/docs/ux-overhaul/production-route-migration.md b/docs/ux-overhaul/production-route-migration.md index c64dd45..6bf55a5 100644 --- a/docs/ux-overhaul/production-route-migration.md +++ b/docs/ux-overhaul/production-route-migration.md @@ -1,6 +1,6 @@ # Production Route Migration -Phase 1 preserves route compatibility and establishes canonical role destinations without deleting deep links. Phase 2 begins the student `/learn` migration while keeping legacy student deep links available. Phase 3 adds canonical teacher `/teach` routes while preserving `/home` and workspace cohort deep links. Phase 4 adds the guarded `/admin` family and retains legacy Admin deep links. Phase 5 adds the guarded `/studio` family over the supported V2 content-operations APIs while retaining all `/workspace/**` routes. +Phase 1 preserves route compatibility and establishes canonical role destinations without deleting deep links. Phase 2 begins the student `/learn` migration while keeping legacy student deep links available. Phase 3 adds canonical teacher `/teach` routes while preserving `/home` and workspace cohort deep links. Phase 4 adds the guarded `/admin` family and retains legacy Admin deep links. Phase 5 adds the guarded `/studio` family over the supported V2 content-operations APIs while retaining all `/workspace/**` routes. Phase 6 adds the active-organization-scoped `/organization` family while retaining all legacy routes. | Current route | Canonical route | Role | Redirect behavior | Guard behavior | Migration phase | Removal criteria | Deep-link risk | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -45,6 +45,13 @@ Phase 1 preserves route compatibility and establishes canonical role destination | `/home/certifications` | `/learn/certificates` for student-origin actions | student transitional | No redirect | Existing child route, no added guard | Phase 2 partial | Existing links aliased and achievements page redesigned | Medium | | `/home/lesson/:id` | `/learn/lesson/:id` for student-origin actions | student transitional, staff preview transitional | No redirect | Existing authenticated guard; governed segment restore unchanged | Phase 2 partial | Remove only after staff lesson preview route is explicit and student bookmarks have an alias | High | | `/home/me/preferences` | `/home/me/preferences` | all authenticated roles | No redirect | Existing authenticated shell | Phase 1 | Dedicated settings route aliases are implemented | Medium | +| `/workspace/learners` | `/organization` | org_admin | Legacy route remains; canonical navigation uses `/organization` | `HomeSessionGuard` plus `RoleGuard`; APIs verify the active organization | Phase 6 | Remove after bookmark and usage validation | Medium | +| No safe legacy member directory | `/organization/members`, `/organization/teachers`, `/organization/students` | org_admin | New canonical routes | Active membership guard plus minimized scoped member API | Phase 6 | None planned | Low | +| `/workspace/settings/invites` | `/organization/invitations` | org_admin | Legacy route remains; canonical page omits secret tokens and unsupported actions | Canonical route is org-admin-only; backend validates requested organization | Phase 6 | Redirect after content-admin mismatch and bookmarks are resolved | High | +| `/workspace/learners/cohorts` and `/home/sections` | `/organization/sections` | org_admin | Legacy teacher-oriented routes remain | Canonical route uses organization-scoped section reads | Phase 6 | Keep while Teach and Organization responsibilities differ | High | +| `/workspace/learners/cohorts/:id` and `/home/sections/:id` | `/organization/sections/:sectionId` | org_admin | Legacy routes remain | Nested backend reads reject out-of-organization section IDs | Phase 6 | Keep while the teacher assignment workflow remains separate | High | +| `/workspace/access` | `/organization/courses` | org_admin | Legacy grant screen remains; canonical availability is read-only | Organization guard plus workspace-scoped product/grant reads | Phase 6 | Replace after member-safe grant mutation exists | High | +| `/workspace/settings` | `/organization/settings` | org_admin | Legacy mixed settings remain | Organization guard; backend permits active organization name update only | Phase 6 | Redirect after personal and organization settings are separated | Medium | ## Canonical Login Destinations @@ -52,7 +59,7 @@ Phase 1 preserves route compatibility and establishes canonical role destination - `teacher`: `/teach` - `instructor`: `/teach` - `content_admin`: `/studio` -- `org_admin`: `/workspace/learners` +- `org_admin`: `/organization` - `admin`: `/admin` - `super_admin`: `/admin` with partial compatibility state @@ -71,3 +78,6 @@ Phase 1 preserves route compatibility and establishes canonical role destination - Phase 5 keeps `/workspace/**` intact because those routes are shared by several creator roles and demo tooling. - Canonical Studio intentionally omits lesson, assessment, asset, upload, and curriculum-ordering routes because the current content-admin APIs do not safely support those workflows. - Studio draft preview reads only V2 artifact content and does not call learner runtime, enrollment, lesson-session, or progress APIs. +- Phase 6 preserves workspace and Teach deep links; no broad route deletion was performed. +- Organization member lists use a minimized scoped API, and class nested reads validate the requested section against the active organization. +- Organization course availability is read-only until access-grant targeting can enforce active-organization membership. diff --git a/docs/ux-overhaul/shared-component-status.md b/docs/ux-overhaul/shared-component-status.md index f4f2bb6..6fd073c 100644 --- a/docs/ux-overhaul/shared-component-status.md +++ b/docs/ux-overhaul/shared-component-status.md @@ -14,6 +14,9 @@ | Component | Path | Status | Notes | | --- | --- | --- | --- | | Button | `frontend/src/app/components/echo-button/echo-button.component.ts` | Extended in Phase 2 | Adds `secondary` color support and optional `ariaLabel` for accessible student action labels. | +| Shared form controls | `frontend/src/app/components/echo-{input,select,textarea,checkbox,radio,radio-group,toggle}/` | Upgraded | Stable labels and IDs, hint/error/success descriptions, disabled/loading/required states, native semantics, visible focus, and 44px targets. | +| Search | `frontend/src/app/components/echo-search/` | Available | Named native search input with clear action, result count announcement, loading state, and Storybook coverage. | +| Responsive data list | `frontend/src/app/components/echo-data-list/` | Available | Equivalent captioned table and mobile record views with named actions, result announcements, and empty state. | | Confirmation dialog | `frontend/src/app/components/echo-confirmation-dialog/` | Connected to assessment submission | Used as a neutral consequential confirmation before assessment attempts. | | Lesson viewer | `frontend/src/app/shared/lesson-viewer.component.*` | Migrated for local activity states | Adds activity instruction, quiz validation alert, and current step state text without adding scoring behavior. | | Certifications page | `frontend/src/app/pages/certifications/` | Migrated for student achievements | Uses shared loading, empty, and error states and learner-facing certificate language. | @@ -21,6 +24,7 @@ | Header | `frontend/src/app/components/echo-header/` | Production active | Uses role-aware shell titles and shared state panels for organization states. | | Sidebar | `frontend/src/app/components/echo-sidebar/` | Production active | Uses role-aware navigation sections and tokenized active/focus states. | | State panel | `frontend/src/app/components/echo-state-panel/` | Production active | Adds `permission` and `success` variants and real icon rendering. | +| Standard page states | `frontend/src/app/components/echo-state-panel/`, `echo-loading-state/` | Production active | Loading, empty, error, success, unavailable, blocked, and permission states support impact/context, retry and safe secondary actions, compact/mobile layouts, live semantics, and reduced motion. | | Access denied | `frontend/src/app/pages/access-denied/` | Migrated | Uses shared permission state. | | Icon registry | `frontend/src/app/shared/icon/icon.module.ts` | Extended | Adds `Lock` and `CheckCircle` paths. | | Student curriculum | `frontend/src/app/components/student-curriculum/student-curriculum.component.ts` | Reused in teacher preview | Teacher course preview renders the same unit/lesson hierarchy in preview-only mode without starting learner progress. | @@ -37,10 +41,9 @@ ## Still Legacy -- Form controls, tables, filter controls, page-specific buttons, dashboard cards, and internal page layouts are still mixed Tailwind/SCSS implementations. +- Tables, page-specific buttons, dashboard cards, and internal page layouts are still mixed Tailwind/SCSS implementations; shared controls are available for incremental adoption. - Existing Storybook/design-lab artifacts remain references; production consumers were not migrated wholesale. - Destructive actions still need page-by-page adoption of `EchoConfirmationDialogComponent`. -- Form controls, tables, filter controls, page-specific buttons, dashboard cards, and internal page layouts are still mixed Tailwind/SCSS implementations. - Activity controls delegated to canvas, map, and storybook components still need full accessibility and responsive QA. - Global teacher assignment list is still a class-first alias because no global assignment API was verified. - Teacher feedback panels remain deferred because no feedback persistence endpoint was verified. @@ -75,3 +78,13 @@ - Existing Admin responsive table styles were not reused as a separate Studio system; Studio uses content-oriented responsive records and forms. Studio does not add an asset picker, reorder control, block editor, tab system, or upload-progress component because no verified content-admin API requires those controls. + +## Phase 6 Organization adoption + +- `EchoLoadingStateComponent` and `EchoStatePanelComponent`: overview, people, invitations, classes, course availability, and settings states. +- `EchoConfirmationDialogComponent`: privileged organization invitations with exact role and organization impact. +- Shared shell/navigation: canonical `/organization` destination and org-admin-only navigation without Teacher, Admin, or Studio controls. +- Shared semantic tokens and responsive records: organization metrics, filters, member/invite/class/offering lists, details, and forms. +- Existing organization, invite, section, analytics, and V2 services were extended or reused; no Organization-only component framework was introduced. + +Member mutation, invitation cancellation/resend, editable course access, and class authoring were not created because current APIs do not safely support them. diff --git a/frontend/package.json b/frontend/package.json index 7007f8f..d5e1c3f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "test": "ng test", "smoke:demo:student": "playwright test tests/demo/student-flagship-smoke.spec.ts", "smoke:demo:admin": "playwright test tests/demo/admin-platform-smoke.spec.ts", - "smoke:demo:studio": "playwright test tests/demo/studio-content-smoke.spec.ts" + "smoke:demo:studio": "playwright test tests/demo/studio-content-smoke.spec.ts", + "smoke:demo:organization": "playwright test tests/demo/organization-admin-smoke.spec.ts" }, "private": true, "dependencies": { diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts index 88b6b8b..7c1a183 100644 --- a/frontend/src/app/app.routes.spec.ts +++ b/frontend/src/app/app.routes.spec.ts @@ -20,6 +20,8 @@ import { AdminUsersComponent } from './pages/admin-users/admin-users.component'; import { AdminOrganizationsComponent } from './pages/admin-organizations/admin-organizations.component'; import { StudioOverviewComponent } from './pages/studio-overview/studio-overview.component'; import { StudioLibraryComponent } from './pages/studio-library/studio-library.component'; +import { OrganizationOverviewComponent } from './pages/organization-overview/organization-overview.component'; +import { OrganizationPeopleComponent } from './pages/organization-people/organization-people.component'; function findRoute(routeList: Routes, path: string): Route | undefined { const exactRoute = routeList.find(candidate => candidate.path === path); @@ -175,6 +177,28 @@ describe('app routes', () => { expect(findRoute(routes, 'studio/courses')?.data?.['roles']).toEqual(['content_admin']); }); + it('adds guarded canonical Organization routes while preserving workspace deep links', () => { + [ + 'organization', + 'organization/members', + 'organization/teachers', + 'organization/students', + 'organization/invitations', + 'organization/sections', + 'organization/sections/:sectionId', + 'organization/courses', + 'organization/settings', + 'workspace/learners', + 'workspace/settings/invites', + 'workspace/learners/cohorts', + ].forEach(path => expect(findRoute(routes, path)).withContext(path).toBeTruthy()); + + expect(findRoute(routes, 'organization/')?.component).toBe(OrganizationOverviewComponent); + expect(findRoute(routes, 'organization/members')?.component).toBe(OrganizationPeopleComponent); + expect(findRoute(routes, 'organization/members')?.canActivate).toContain(RoleGuard); + expect(findRoute(routes, 'organization/members')?.data?.['roles']).toEqual(['org_admin']); + }); + it('maps Learner Portal V2 routes to V2 pages while reusing lesson runtime', () => { const learnRoute = findRoute(routes, 'learn'); const learnerShell = learnRoute?.children?.find(route => route.path === ''); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index f59ecf5..efedba5 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -56,6 +56,13 @@ import { StudioProjectDetailComponent } from './pages/studio-project-detail/stud import { StudioContentDetailComponent } from './pages/studio-content-detail/studio-content-detail.component'; import { StudioDraftDetailComponent } from './pages/studio-draft-detail/studio-draft-detail.component'; import { StudioReviewComponent } from './pages/studio-review/studio-review.component'; +import { OrganizationOverviewComponent } from './pages/organization-overview/organization-overview.component'; +import { OrganizationPeopleComponent } from './pages/organization-people/organization-people.component'; +import { OrganizationInvitationsComponent } from './pages/organization-invitations/organization-invitations.component'; +import { OrganizationSectionsComponent } from './pages/organization-sections/organization-sections.component'; +import { OrganizationSectionDetailComponent } from './pages/organization-section-detail/organization-section-detail.component'; +import { OrganizationCoursesComponent } from './pages/organization-courses/organization-courses.component'; +import { OrganizationSettingsComponent } from './pages/organization-settings/organization-settings.component'; const creatorRoles = ['admin', 'teacher', 'content_admin', 'org_admin', 'instructor']; const studioRoles = ['content_admin', 'org_admin']; @@ -105,6 +112,22 @@ export const routes: Routes = [ { path: 'publishing', component: StudioLibraryComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'], mode: 'publishing' } }, ], }, + { + path: 'organization', + component: HomeComponent, + canActivate: [HomeSessionGuard], + children: [ + { path: '', component: OrganizationOverviewComponent, canActivate: [RoleGuard], data: { roles: ['org_admin'] } }, + { path: 'members', component: OrganizationPeopleComponent, canActivate: [RoleGuard], data: { roles: ['org_admin'], mode: 'members' } }, + { path: 'teachers', component: OrganizationPeopleComponent, canActivate: [RoleGuard], data: { roles: ['org_admin'], mode: 'teachers' } }, + { path: 'students', component: OrganizationPeopleComponent, canActivate: [RoleGuard], data: { roles: ['org_admin'], mode: 'students' } }, + { path: 'invitations', component: OrganizationInvitationsComponent, canActivate: [RoleGuard], data: { roles: ['org_admin'] } }, + { path: 'sections', component: OrganizationSectionsComponent, canActivate: [RoleGuard], data: { roles: ['org_admin'] } }, + { path: 'sections/:sectionId', component: OrganizationSectionDetailComponent, canActivate: [RoleGuard], data: { roles: ['org_admin'] } }, + { path: 'courses', component: OrganizationCoursesComponent, canActivate: [RoleGuard], data: { roles: ['org_admin'] } }, + { path: 'settings', component: OrganizationSettingsComponent, canActivate: [RoleGuard], data: { roles: ['org_admin'] } }, + ], + }, { path: 'teach', component: HomeComponent, diff --git a/frontend/src/app/components/echo-button/echo-button.component.ts b/frontend/src/app/components/echo-button/echo-button.component.ts index 8156cfe..3197205 100644 --- a/frontend/src/app/components/echo-button/echo-button.component.ts +++ b/frontend/src/app/components/echo-button/echo-button.component.ts @@ -8,17 +8,25 @@ import { CommonModule } from '@angular/common'; imports: [CommonModule], template: ` `, }) @@ -27,4 +35,11 @@ export class EchoButtonComponent { @Input() color: 'primary' | 'accent' | 'secondary' | 'warn' | 'tertiary' = 'primary'; @Input() disabled = false; @Input() ariaLabel = ''; + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + @Input() size: 'sm' | 'md' | 'lg' = 'md'; + @Input() loading = false; + @Input() loadingLabel = 'Working'; + @Input() icon = ''; + @Input() iconOnly = false; + @Input() tooltip = ''; } diff --git a/frontend/src/app/components/echo-button/echo-button.stories.ts b/frontend/src/app/components/echo-button/echo-button.stories.ts index d15b1a4..64fb64f 100644 --- a/frontend/src/app/components/echo-button/echo-button.stories.ts +++ b/frontend/src/app/components/echo-button/echo-button.stories.ts @@ -15,7 +15,7 @@ const meta: Meta = { argTypes: { color: { control: 'select', - options: ['primary', 'accent', 'tertiary', 'warn'], + options: ['primary', 'accent', 'secondary', 'tertiary', 'warn'], }, disabled: { control: 'boolean', @@ -60,4 +60,12 @@ export const Disabled: Story = { color: 'primary', disabled: true, }, -}; \ No newline at end of file +}; + +export const Loading: Story = { + args: { label: 'Save changes', loading: true, loadingLabel: 'Saving' }, +}; + +export const IconOnly: Story = { + args: { label: 'Add', icon: '+', iconOnly: true, ariaLabel: 'Add learner', tooltip: 'Add learner' }, +}; diff --git a/frontend/src/app/components/echo-checkbox/echo-checkbox.component.ts b/frontend/src/app/components/echo-checkbox/echo-checkbox.component.ts index 5799182..3557c2a 100644 --- a/frontend/src/app/components/echo-checkbox/echo-checkbox.component.ts +++ b/frontend/src/app/components/echo-checkbox/echo-checkbox.component.ts @@ -1,25 +1,44 @@ +import { CommonModule } from '@angular/common'; import { Component, Input, Output, EventEmitter } from '@angular/core'; +let nextCheckboxId = 0; + @Component({ selector: 'app-echo-checkbox', standalone: true, template: ` -