diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index a5d080a..7ad3d8a 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -19,9 +19,17 @@ def get_users( return db.query(User).all() +@router.get("/users/students") +def get_student_users( + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + return db.query(User).filter(User.role == "student").all() + + @router.get("/users/{user_id}") def get_user_by_id( - user_id: str, + user_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(require_roles("admin")), ): @@ -31,17 +39,9 @@ def get_user_by_id( return user -@router.get("/users/students") -def get_student_users( - db: Session = Depends(get_db), - current_user: User = Depends(require_roles("admin", "teacher")), -): - return db.query(User).filter(User.role == "student").all() - - @router.put("/users/{user_id}") def update_user( - user_id: str, + user_id: uuid.UUID, user: UserDto, db: Session = Depends(get_db), current_user: User = Depends(require_roles("admin")), diff --git a/backend/tests/test_admin_users.py b/backend/tests/test_admin_users.py new file mode 100644 index 0000000..b5b6da6 --- /dev/null +++ b/backend/tests/test_admin_users.py @@ -0,0 +1,88 @@ +import uuid + +from fastapi.testclient import TestClient + +from app.auth import get_current_user +from app.database import get_db +from app.main import app +from app.models import User + + +def _user(role: str, prefix: str) -> User: + suffix = uuid.uuid4().hex[:8] + return User( + id=uuid.uuid4(), + firstname=prefix.title(), + lastname="User", + username=f"{prefix}_{suffix}", + email=f"{prefix}_{suffix}@example.test", + hashed_password="unchanged-password-hash", + role=role, + ) + + +def _client(db_session, current_user: User) -> TestClient: + app.dependency_overrides[get_db] = lambda: db_session + app.dependency_overrides[get_current_user] = lambda: current_user + return TestClient(app) + + +def test_admin_can_fetch_user_detail_by_uuid_on_sqlite(db_session): + admin = _user("admin", "admin") + student = _user("student", "student") + db_session.add_all([admin, student]) + db_session.commit() + + client = _client(db_session, admin) + try: + response = client.get(f"/api/users/{student.id}") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json()["id"] == str(student.id) + assert response.json()["role"] == "student" + + +def test_admin_can_update_user_role_by_uuid_without_changing_password(db_session): + admin = _user("admin", "admin") + student = _user("student", "student") + db_session.add_all([admin, student]) + db_session.commit() + + client = _client(db_session, admin) + try: + response = client.put( + f"/api/users/{student.id}", + json={ + "firstname": student.firstname, + "lastname": student.lastname, + "username": student.username, + "email": student.email, + "password": "", + "role": "teacher", + }, + ) + finally: + app.dependency_overrides.clear() + + db_session.refresh(student) + assert response.status_code == 200 + assert student.role == "teacher" + assert student.hashed_password == "unchanged-password-hash" + + +def test_static_student_list_route_is_not_shadowed_by_user_detail(db_session): + teacher = _user("teacher", "teacher") + student = _user("student", "student") + db_session.add_all([teacher, student]) + db_session.commit() + + client = _client(db_session, teacher) + try: + response = client.get("/api/users/students") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert [item["id"] for item in response.json()] == [str(student.id)] diff --git a/docs/ux-overhaul/admin-accessibility-review.md b/docs/ux-overhaul/admin-accessibility-review.md new file mode 100644 index 0000000..c5549bc --- /dev/null +++ b/docs/ux-overhaul/admin-accessibility-review.md @@ -0,0 +1,22 @@ +# Admin Accessibility Review + +Date: 2026-07-13 + +## Implemented Checks + +- One named page `h1`, logical section headings, semantic `main`, tables, captions, headers, definition lists, forms, labels, and buttons. +- Desktop semantic tables have equivalent mobile records with the same identity, status, and primary action. +- Search/filter result counts and asynchronous states use live status semantics through shared components. +- Status always includes text; no meaning relies on color. +- Controls have 44px minimum height, visible tokenized focus rings, and no hover-only actions. +- Shared confirmation dialog traps focus, supports Escape, restores focus, names the object and consequence, and exposes action errors. +- Badge images use meaningful alt text; missing images have a named alternative. +- Form validation associates visible labels and `aria-invalid`; badge form errors are announced. +- Long names, usernames, and email addresses wrap in detail definitions. +- Existing shell skip link, mobile drawer focus behavior, and reduced-motion foundation remain unchanged. + +## Validation + +Karma coverage exercises state semantics, privacy omission, confirmation gating, navigation, deep-link roles, and image naming. Seeded Playwright covers primary Admin navigation, filtering, dialog cancel, unauthorized deep link, and 390px overflow when services are available. + +Manual screen-reader and browser zoom execution remains open with seeded browser infrastructure. Sorting and pagination are not present because current APIs return unpaged lists and this phase does not invent server behavior. diff --git a/docs/ux-overhaul/admin-action-risk-matrix.md b/docs/ux-overhaul/admin-action-risk-matrix.md new file mode 100644 index 0000000..ac909d8 --- /dev/null +++ b/docs/ux-overhaul/admin-action-risk-matrix.md @@ -0,0 +1,14 @@ +# Admin Action Risk Matrix + +Date: 2026-07-13 + +| Action | Classification | Object / backend behavior | Reversible | Confirmation / permission | Recovery and audit gap | +| --- | --- | --- | --- | --- | --- | +| Review user/course/org/badge/report | Informational | GET only | N/A | Guarded route and backend auth | Retryable partial/error states. | +| Create badge | Consequential | Creates badge and optional uploaded image | No delete API | Valid form; `admin` | Failure leaves UI unchanged; uploaded orphan cleanup not supported. | +| Change platform role | Privilege-changing | Rewrites full user DTO including role | Yes by another update | Names user and access impact; `admin`; self-change disabled | Failure retains old role; no audit/final-admin enforcement. | +| Delete user | Destructive | Deletes user plus authored posts/threads, badge awards, and unit links | No | Names user, scope, unaffected objects, irreversibility; `admin`; self-delete disabled in UI | Failure retains record; no restore/audit/final-admin protection. | +| Delete course | Destructive | Deletes course record | No | Names course, learner-access impact, no archive/restore; `admin` | Failure retains record; no restore/audit. | +| Switch organization | Consequential | Reissues token with active organization | Yes | Existing shell interaction | Existing session recovery. Not redesigned here. | + +Unsupported disable/restore, invitation resend, organization archive/delete, badge edit/delete, moderation, and platform-setting actions are absent. diff --git a/docs/ux-overhaul/admin-badge-data-contract.md b/docs/ux-overhaul/admin-badge-data-contract.md new file mode 100644 index 0000000..8915552 --- /dev/null +++ b/docs/ux-overhaul/admin-badge-data-contract.md @@ -0,0 +1,20 @@ +# Admin Badge Data Contract + +Date: 2026-07-13 + +| Field/capability | Verified contract | Admin UI behavior | +| --- | --- | --- | +| ID | UUID | Used only for API identity; not displayed. | +| Name | `title`, required | Displayed and searchable. | +| Description | Optional text | Displayed; missing value is stated. | +| Image | Optional `image_url` | Named image alt text; explicit missing-image alternative. | +| Earning criteria | No field/API | Not invented or editable. | +| Course/program relation | No badge relation in response | Not shown. Certifications may reference a badge separately. | +| Active/inactive | No field/API | Not shown. | +| Create | `POST /api/badges`, `admin` | Supported with validation and success/error states. | +| Upload | `POST /api/upload/badge`, `admin` | Optional image upload during creation. | +| Edit | No endpoint | Read-only unavailable. | +| Delete/archive | No endpoint | No control or confirmation shown. | +| Assignment | `POST /api/students/{student_id}/badges/{badge_id}`, `admin` | Verified but not added to broad badge administration because no scoped learner-selection workflow was approved. | + +Badge reads require authentication but are not admin-only. `super_admin` therefore receives a read-only catalog; it receives no create/upload control. diff --git a/docs/ux-overhaul/admin-moderation-capability-map.md b/docs/ux-overhaul/admin-moderation-capability-map.md new file mode 100644 index 0000000..304b5f8 --- /dev/null +++ b/docs/ux-overhaul/admin-moderation-capability-map.md @@ -0,0 +1,13 @@ +# Admin Moderation Capability Map + +Date: 2026-07-13 + +| Content type | Read API | Action API | Authorization | Reversible/auditable | Admin decision | +| --- | --- | --- | --- | --- | --- | +| Threads | `GET /api/threads*` | Create/update/delete | None | Delete is irreversible; no audit | Not exposed. APIs have no auth, scope, ownership, flag, lock, hide, restore, or moderator contract. | +| Posts | `GET /api/posts*` | Create/update/delete | None | Delete is irreversible; no audit | Not exposed for the same reason. | +| Course discussions | No scoped moderation API | None | None verified | No | Not exposed. | +| Uploaded content | No asset-list API | Upload only | Role-specific upload checks | No delete/audit | No moderation or asset-library UI. Badge upload remains inside badge creation. | +| Flags/reports | No API | None | None | No | No queue, counts, or controls. | + +No `/admin/moderation` route is introduced. A platform queue built from unscoped thread/post CRUD would imply authorization, reports, and consequences that do not exist. diff --git a/docs/ux-overhaul/admin-privacy-review.md b/docs/ux-overhaul/admin-privacy-review.md new file mode 100644 index 0000000..32715f2 --- /dev/null +++ b/docs/ux-overhaul/admin-privacy-review.md @@ -0,0 +1,18 @@ +# Admin Privacy Review + +Date: 2026-07-13 + +## High-Risk Surfaces And Mitigations + +| Surface | Risk | Production mitigation | +| --- | --- | --- | +| User directory | Broad disclosure of contact/security data | Shows display name, role, and creation date only. Email and username appear only after opening one protected record. Raw ID is not visible. | +| Backend user serialization | ORM response may include hashed password/internal fields | Typed frontend projection never renders unknown fields. Dedicated backend response schema remains a gap. | +| User detail | Mixing platform identity with learner progress | Shows identity and platform role only; no learner progress, demographics, tokens, password data, or global activity. | +| Organizations | Cross-organization disclosure | Uses membership-scoped `/api/orgs`; neutral unavailable state for unknown deep links; does not infer members/admins/classes/invites. | +| Courses | Learner and author identity exposure | Shows curriculum/governance metadata, not learner progress or teacher assignment data. Creator/organization UUIDs are not displayed. | +| Reporting | Misleading or identifying analytics | Uses aggregate documented counts only; no learner/organization ranking or individual activity. | +| Errors | Sensitive payload leakage | User-facing messages are neutral and do not echo backend payloads or identifiers. | +| Destructive operations | Hidden scope | Confirmations state known cascades and unaffected organizations/courses. | + +Backend authorization remains authoritative for every API call and `RoleGuard` protects deep links after session bootstrap. The frontend does not log administrative payloads. Organization and learner contexts remain separate. diff --git a/docs/ux-overhaul/admin-production-audit.md b/docs/ux-overhaul/admin-production-audit.md new file mode 100644 index 0000000..c3de0d7 --- /dev/null +++ b/docs/ux-overhaul/admin-production-audit.md @@ -0,0 +1,48 @@ +# Platform Admin Production Audit + +Date: 2026-07-13 + +## Authorization Summary + +The platform role stored on `User.role` is the backend authority. `require_roles()` performs exact string matching and does not implement inheritance. Organization roles are separate `OrganizationMembership.role` values. Frontend permissions and hidden navigation are presentation only. + +`admin` is the only role authorized for the current user directory, user updates/deletion, Admin overview analytics, course list/detail/write operations, badge creation/assignment, and badge image upload. `super_admin` is not an alias for `admin`: it can bypass organization-name update membership checks and can read unscoped V2 analytics, but it is rejected by the core Admin endpoints above. The current shell previously displayed an Admin area for both roles while legacy Admin routes allowed only `admin`. + +## Route And Screen Audit + +| Current route or surface | User goal | Authorized role from backend | APIs | Sensitivity | Current actions | Backend enforcement | UX, accessibility, and responsive risks | Recommendation / owner | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `/home` Admin dashboard | Assess platform state | `admin`; `super_admin` cannot call its core APIs | `GET /api/analytics/overview`, `/api/users`, `/api/courses`, course governance endpoints | Platform user counts; learner recommendations | Read summaries; legacy page also deletes users/courses | Exact role checks for lists; governance allows staff roles | 546-line dashboard mixes operational oversight, learner intervention data, and destructive actions; partial failures are difficult to distinguish; dense mobile layout | Canonical `/admin`; Admin. Keep `/home` as a legacy landing until dynamic compatibility routing is retired. | +| `/home/admin/users` | Review and manage accounts | `admin` | `GET /api/users`, `DELETE /api/users/{id}` | Names, email, username, role, created date; API also serializes hashed password unless excluded by ORM serialization | Immediate delete | Exact `admin` check; delete cascades posts, threads, badges, unit links and user | No loading/error/empty state, no confirmation, no search/filter, raw role text, table overflows mobile | Canonical `/admin/users`; Admin. Minimize visible fields and require exact destructive confirmation. | +| No user-detail route | Inspect one account and change role | `admin` | `GET/PUT /api/users/{id}` | Identity, email, role | Full DTO update; role change possible | Exact `admin` check; no role allowlist, final-admin protection, or self-lockout protection | Missing UI; PUT requires complete identity DTO and optional empty password convention | Add `/admin/users/:userId`; Admin. Allow verified role changes except self-change; confirm privilege changes; document backend gaps. | +| `/workspace/learners/users` | Duplicate user list | `admin` | Same as user list | Same | Same | Same | Duplicate namespace suggests organization ownership | Keep as compatibility route; canonical `/admin/users`; Admin. | +| `/home/admin/courses` | Oversee courses | `admin` for list/write | `GET/DELETE /api/courses`, course governance endpoints | Curriculum metadata and learner-facing availability | Legacy create/edit/delete | Exact `admin`/`teacher` on core course CRUD | “Product” language, authoring controls mixed into Admin, immediate delete, no states, table overflows | Canonical `/admin/courses`; Admin oversight only. Link to detail oversight; keep authoring out of Admin. | +| `/workspace/products/manage` | Duplicate course management | `admin` | Same as course list | Same | Same | Same | Duplicates Admin and Studio concepts | Compatibility alias only; canonical `/admin/courses`; Admin. | +| `/home/admin/badges` | Create and inspect badges | Read: any authenticated user; create/upload/assign: `admin` | `GET/POST /api/badges`, `POST /api/upload/badge`, assignment endpoint | Public recognition metadata and images | Create and upload | Explicit `admin` checks on writes | Missing load/error/empty/submission states; inaccessible image; file restrictions not explained; no edit/delete API exists | Canonical `/admin/badges`; Admin. Create supported; existing badges read-only. | +| `/workspace/settings/badges` | Duplicate badge management | Route says `admin` | Same as badge list | Same | Same | Same | Wrong settings ownership | Compatibility alias only; canonical `/admin/badges`; Admin. | +| `/workspace/analytics` | Review workspace/content analytics | Several staff roles including `super_admin` | V2 analytics endpoints | Organization/workspace aggregate operational data | Read only | Exact role list plus workspace scoping; admins/super admins unscoped | Content/Studio reporting is not the same as platform Admin reporting | Keep in Studio. Add `/admin/reports` using only `/analytics/overview` for `admin`; no cross-link for `super_admin` unless definitions match. | +| `/workspace/commercial` | Community project surface | Creator roles | V2/community APIs | Community operational data | Varies | Creator-role route checks | Not moderation and not Admin oversight | Community owner; remove from Admin navigation. Preserve route. | +| `/workspace/settings` | Preferences/settings | Creator roles | Preferences APIs | User preferences | Edit preferences | Existing auth | Exposed as “Platform settings” without a platform-settings API | Keep as user/Studio settings; omit from Admin navigation. | +| `/workspace/review-center` | Content review | Creator roles | V2 review APIs | Content review state | Content workflow actions | Creator-role checks | Belongs to Studio; not platform moderation | Studio owner; do not migrate. | +| `/workspace/access` | Product access grants | Creator roles | V2 access-grant APIs | Learner access | Grant/revoke | V2 role and workspace checks | Organization/content access, not platform account access | Organization/Studio owner; do not migrate. | +| Organization selector/header | See organizations for current account | Any authenticated user | `GET /api/orgs` | Organization identity, current membership role | Switch active organization; create organization elsewhere | List is restricted to current user's memberships; PATCH allows member content/org admin or platform `super_admin` | Cannot provide platform-wide oversight, counts, members, admins, sections, or invitations | Add `/admin/organizations` as explicitly scoped read-only membership visibility. Detail is limited to returned organization metadata. Admin owner for visibility; self-service remains Organization. | +| Threads/posts routes | Discuss or mutate community content | No authentication dependency | CRUD `/api/threads`, `/api/posts` | User-generated content and author identifiers | Create/update/delete | No authentication, scope, ownership, moderation role, report, restore, lock, or audit behavior | Unsafe for platform moderation; deletion irreversible and unscoped | No Admin route. Document as backend security/moderation gap; Community future change. | +| Upload routes | Upload lesson assets | Badge upload: `admin`; coloring/storybook: `admin` or `teacher` | `/api/upload/*` | Uploaded files | Create file | Exact role checks | No asset list, metadata, delete, ownership, or moderation APIs | Badge upload stays inside badge creation. No Admin asset library. | + +## Destructive Behavior + +- User deletion permanently removes the user plus authored posts/threads, student badges, and unit links. The endpoint has no self-delete protection, final-admin protection, audit record, dry run, or restore. +- Course deletion permanently deletes the course through the current CRUD endpoint and has no archive/restore contract. +- Badge deletion, badge edit, organization archive/delete, account disable/restore, invitation resend, role removal, membership management, and platform settings are not supported by verified APIs. + +## Route Migration Decision + +The canonical family is `/admin`, `/admin/users`, `/admin/users/:userId`, `/admin/organizations`, `/admin/organizations/:organizationId`, `/admin/courses`, `/admin/courses/:courseId`, `/admin/badges`, and `/admin/reports`. Moderation and platform settings routes are omitted. Legacy routes remain guarded aliases; no broad deletion is part of this phase. + +## Primary Risks And Mitigations + +- **Authorization mismatch:** exact backend role checks remain authoritative; `super_admin` receives only the supported overview limitation state and organization visibility. +- **Privacy:** broad user lists show name, role, and created date only. Email and username are confined to an individual detail view. Password hashes, tokens, and raw identifiers are never rendered. +- **Responsive tables:** lists switch from semantic desktop tables to equivalent mobile records without hiding primary actions. +- **High-impact actions:** shared confirmation names the object, consequence, scope, and irreversibility; UI updates only after API success. +- **Partial failures:** each overview source reports independently so one failed request does not make successful counts appear absent. diff --git a/docs/ux-overhaul/admin-production-implementation.md b/docs/ux-overhaul/admin-production-implementation.md new file mode 100644 index 0000000..aac58dc --- /dev/null +++ b/docs/ux-overhaul/admin-production-implementation.md @@ -0,0 +1,45 @@ +# Platform Admin Production Implementation + +Date: 2026-07-13 + +## Delivered + +- Canonical guarded routes under `/admin` for overview, users/detail, organizations/detail, courses/detail, badges, and reports. +- Role-accurate navigation and canonical Admin login destination. +- Partial-failure Admin overview with supported user, role, organization-membership, course, badge, and analytics counts. +- Privacy-minimized responsive user directory; individual identity/role detail; confirmed role changes; self-role/self-delete prevention; confirmed permanent deletion. +- Explicitly scoped, read-only organization visibility using only current-account memberships. +- Course oversight and governance summary separated from Content Studio; no authoring controls; confirmed destructive deletion. +- Badge creation/upload for `admin`, read-only catalog for compatible `super_admin`, accessible image naming, and explicit unsupported lifecycle behavior. +- Defined platform report over `/api/analytics/overview`. +- Shared loading, empty, error, permission, success, and confirmation patterns across new pages. +- Seeded Admin Playwright smoke and guarded student deep-link scenario. + +## Components Created + +`AdminOverviewComponent`, `AdminUserDetailComponent`, `AdminOrganizationsComponent`, and `AdminReportsComponent`; shared Admin production layout styles in `frontend/src/styles/_admin-production.scss`. + +## Components Migrated + +`AdminUsersComponent`, `AdminCoursesComponent`, `AdminBadgesComponent`, role selector, route configuration, shell navigation, and permission navigation mapping. + +## APIs Used + +`GET /api/users`, `GET/PUT/DELETE /api/users/{id}`, `GET /api/orgs`, `GET /api/courses`, `GET /api/courses/{id}`, `GET /api/courses/{id}/governance-summary`, `DELETE /api/courses/{id}`, `GET/POST /api/badges`, `POST /api/upload/badge`, and `GET /api/analytics/overview`. + +## Not Implemented + +Moderation, platform settings, platform-wide organization directory/membership oversight, organization lifecycle actions, badge edit/delete/archive, account disable/restore, invitation actions, audit logs, asset library, and unsupported `super_admin` inheritance. + +## Backend Repair + +The existing user detail and update route parameters now use `uuid.UUID`, preventing UUID-column binding failures. The existing static `/api/users/students` route is registered before `/api/users/{user_id}` so it is no longer shadowed. No schema, migration, new endpoint, seed behavior, or JWT behavior changed. + +## Verification + +- Frontend unit tests: `245 SUCCESS` (baseline was 233). +- Backend unit tests: `224 passed`, including new UUID detail/update and static student-route regressions. +- Production build: passes; existing Sass mixed-declaration deprecation warnings remain. +- Playwright discovery: 3 tests in 2 files, including 2 Admin scenarios. +- Seeded browser execution: executed serially against a disposable, model-created SQLite database because Docker/PostgreSQL were unavailable. The Admin oversight, unauthorized Admin deep-link, and student flagship flows pass. Result: `3 passed`. +- OpenSpec: `openspec validate overhaul-role-based-ui-ux-experience --strict` passes. diff --git a/docs/ux-overhaul/admin-reporting-data-contract.md b/docs/ux-overhaul/admin-reporting-data-contract.md new file mode 100644 index 0000000..a809e5e --- /dev/null +++ b/docs/ux-overhaul/admin-reporting-data-contract.md @@ -0,0 +1,20 @@ +# Admin Reporting Data Contract + +Date: 2026-07-13 + +Source: `GET /api/analytics/overview`, authorized for `admin` only. + +| Metric | Definition | +| --- | --- | +| Students | Users whose platform role equals `student`. | +| Teachers | Users whose platform role equals `teacher`; instructors are not included. | +| Courses | All course records. | +| Active students | Distinct student IDs with an enrollment record; this is not time-window activity. | +| Total enrollments | All `StudentCourse` records. | +| Incomplete enrollments | Total enrollments minus completed enrollment records. The backend field is `pending_enrollments`; UI avoids implying an invitation/approval state. | +| Lessons completed | Completed `SegmentProgress` records. | +| Units completed | Completed `StudentUnitProgress` records. | +| Courses completed | `StudentCourse` records with status `completed`. | +| Course completion rate | Completed course enrollments divided by total enrollments, rounded by backend. | + +The report does not claim trends, active account state, engagement, growth, organization comparison, audit events, or time ranges. Workspace/V2 analytics remain Studio reporting and are not merged into Admin. diff --git a/docs/ux-overhaul/admin-responsive-review.md b/docs/ux-overhaul/admin-responsive-review.md new file mode 100644 index 0000000..917470b --- /dev/null +++ b/docs/ux-overhaul/admin-responsive-review.md @@ -0,0 +1,16 @@ +# Admin Responsive Review + +Date: 2026-07-13 + +Target widths: 1440, 1280, 768, and 390 CSS pixels. + +- Page width is constrained to the shared 72rem content maximum with responsive page padding. +- Overview metrics and shortcuts use auto-fit grids; alert/attention content remains first in DOM order. +- User and course desktop tables switch below 768px to equivalent stacked records. Desktop tables are removed from layout rather than left focusable off-screen. +- Organization and badge collections use auto-fit grids with a 13rem minimum bounded by container width. +- Filter fields and commands become full-width at mobile sizes; controls retain 44px touch height. +- Detail definition grids switch from two columns to one; identifiers use `overflow-wrap`. +- Dialog responsiveness and focus behavior are supplied by the shared confirmation component. +- No component uses viewport-scaled font sizes, negative letter spacing, fixed page widths, or pointer-only actions. + +Automated build/unit validation passes. Seeded Playwright executes through a disposable SQLite fallback, and the Admin flow completes its 390px horizontal-overflow assertion. The browser smoke suite passes all three scenarios. diff --git a/docs/ux-overhaul/admin-role-access-contract.md b/docs/ux-overhaul/admin-role-access-contract.md new file mode 100644 index 0000000..ebccd38 --- /dev/null +++ b/docs/ux-overhaul/admin-role-access-contract.md @@ -0,0 +1,31 @@ +# Admin Role And Access Contract + +Date: 2026-07-13 + +## Role Sources + +Platform role is `User.role`; organization role is `OrganizationMembership.role`. They are additive in the frontend session but are checked independently by backend dependencies. There is no role inheritance. + +| Role | Product status | Platform scope | Assignable in Admin user detail | Backend notes | +| --- | --- | --- | --- | --- | +| `student` | Implemented | Learner | Yes | Exact role checks protect learner APIs. | +| `teacher` | Implemented | Educator | Yes | Course CRUD and educator APIs recognize it. | +| `admin` | Implemented | Platform | Yes, with confirmation | Only role accepted by core Admin user, summary, and badge-write APIs. | +| `org_admin` | Implemented | Organization membership | No | Must be managed as organization membership; not through platform role control. | +| `content_admin` | Implemented | Organization/content membership | No | Studio/content access; not a platform Admin role. | +| `instructor` | Partial | Educator compatibility | No | Several educator endpoints still omit it. | +| `parent` | Partial | Unsupported portal | No | No complete navigation/API experience. | +| `viewer` | Partial | Unsupported portal | No | No complete navigation/API experience. | +| `super_admin` | Partial | Organization update bypass and V2 analytics | No | Does not inherit `admin`; core Admin APIs reject it. | + +## Role Change Contract + +- Admin can change another account among `student`, `teacher`, and `admin` through `PUT /api/users/{id}`. +- The complete safe identity DTO is resubmitted because the endpoint is not role-specific; password is sent as empty so the backend leaves it unchanged. +- Confirmation names the user and explains platform-area impact. UI state changes only after success. +- Self-role changes are disabled. The backend has no final-admin or self-lockout protection, so the UI also documents this unresolved enforcement gap. +- Organization roles and partial roles are not offered. Navigation is re-evaluated on next authenticated session; authenticated self-change is not exposed. + +## Known Authorization Gaps + +The backend accepts arbitrary `role` strings in `UserDto`, serializes ORM user objects without a dedicated privacy response schema, and does not protect the last administrator. Frontend constraints reduce accidental misuse but are not security boundaries. diff --git a/docs/ux-overhaul/admin-super-admin-compatibility.md b/docs/ux-overhaul/admin-super-admin-compatibility.md new file mode 100644 index 0000000..ce123aa --- /dev/null +++ b/docs/ux-overhaul/admin-super-admin-compatibility.md @@ -0,0 +1,17 @@ +# Admin Super-Admin Compatibility + +Date: 2026-07-13 + +`super_admin` is a partial role, not an inherited superset of `admin`. + +| Surface | `admin` | `super_admin` | Evidence | +| --- | --- | --- | --- | +| Admin shell/overview | Full supported overview | Visible limitation state | Frontend role navigation; core overview API is admin-only. | +| Users | Read/write/delete | Rejected | `require_roles("admin")`. | +| Courses | Read/write/delete | Rejected | `require_roles("admin", "teacher", "student")` and explicit write list. | +| Badges | Read/create/assign/upload | Read only | Badge list requires auth; writes compare role to `admin`. | +| Organizations | Current memberships only | Current memberships only; PATCH bypass exists | List is membership-scoped for every role; PATCH explicitly bypasses for super admin. | +| Admin report | Available | Rejected | Overview analytics requires `admin`. | +| V2 analytics | Unscoped | Unscoped | V2 role list and visibility helper include both. This remains Studio reporting. | + +Canonical `/admin` accepts both roles. Users, courses, and reports routes accept only `admin`; organizations and badge catalog accept both. Navigation mirrors these facts. No control is exposed solely because the role is named `super_admin`. diff --git a/docs/ux-overhaul/backend-gap-register.md b/docs/ux-overhaul/backend-gap-register.md new file mode 100644 index 0000000..fb8a649 --- /dev/null +++ b/docs/ux-overhaul/backend-gap-register.md @@ -0,0 +1,17 @@ +# Backend Gap Register + +Date: 2026-07-13 + +| Role | User need | Current frontend behavior | API limitation | Privacy/security implication | Blocks current workflow | Future OpenSpec change | Priority | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Teacher/instructor | Scoped learner review | Read-only/scoped UI | No complete class-scoped review/feedback persistence | Avoid global learner disclosure | Yes for persistent feedback | Educator review and feedback contract | High | +| Teacher/instructor | Moderate class discussion | Hidden | Thread/post APIs have no auth or class scope | Unscoped destructive access | Yes | Authenticated scoped discussion moderation | Critical | +| Instructor | Use educator analytics consistently | Partial states | Several analytics role lists omit instructor | Role may expose navigation that API rejects | Partial | Instructor authorization alignment | High | +| Admin | Platform-wide organization oversight | Shows current account memberships only | `/api/orgs` is membership-scoped; no member/admin/section summaries | Inferring or bypassing scope risks cross-org disclosure | Yes for requested oversight | Platform organization directory with explicit permission and minimized response | High | +| Admin | Safe role management | Limits roles; blocks self-change | Arbitrary role strings; no final-admin/self-lockout check; full DTO update | Privilege escalation and lockout risk | Partial | Role-specific allowlisted mutation with critical-admin invariant | Critical | +| Admin | Privacy-safe user reads | Typed UI omits sensitive fields; detail deep links work after the UUID route repair | ORM user serialization lacks dedicated minimized summary/detail response schemas | Hashed/internal fields may cross the API boundary even though the UI omits them | No for current UI; yes for API-level minimization | Admin user summary/detail response schemas | High | +| Admin | Disable/restore account | No controls | No status or mutation API | Invented frontend state would be unenforced | Yes | Account access-state lifecycle | Medium | +| Admin | Moderation/report queue | No route/control | No authenticated scope, flags, reports, restore, lock, or audit | Unsafe cross-community disclosure and deletion | Yes | Platform safety and moderation capability | Critical | +| Admin | Audit high-impact actions | Confirmation only | No audit log/event contract | Destructive/privilege changes lack accountability | No, but operational risk | Administrative audit events | High | +| Admin | Badge lifecycle | Create/read only | No edit/archive/delete/criteria relation | Credential meaning cannot be governed | Partial | Badge lifecycle and criteria contract | Medium | +| Admin/super admin | Consistent privilege model | Role-accurate limitation state | Exact checks conflict with presumed super-admin inheritance | Name can cause unsafe assumptions | Partial | Explicit platform permission policy replacing name inference | High | diff --git a/docs/ux-overhaul/production-route-migration.md b/docs/ux-overhaul/production-route-migration.md index ecd3a79..93453cb 100644 --- a/docs/ux-overhaul/production-route-migration.md +++ b/docs/ux-overhaul/production-route-migration.md @@ -1,10 +1,10 @@ # 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 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. | Current route | Canonical route | Role | Redirect behavior | Guard behavior | Migration phase | Removal criteria | Deep-link risk | | --- | --- | --- | --- | --- | --- | --- | --- | -| `/home` | `/teach` for teacher/instructor; `/home` remains admin transitional | teacher, instructor, admin, super_admin-compatible | No redirect | `HomeSessionGuard`; child guards unchanged | Phase 3 teacher alias added | Remove teacher reliance only after `/teach` smoke tests and usage validation | High | +| `/home` | `/teach` for teacher/instructor; `/admin` for admin/super_admin-compatible | teacher, instructor, admin, super_admin-compatible | No redirect; legacy role dashboard remains | `HomeSessionGuard`; child guards unchanged | Phase 4 Admin canonical landing added | Remove role-dashboard reliance only after compatibility redirect and usage validation | High | | `/teach` | `/teach` | teacher, instructor | No redirect; role landing now points here | `HomeSessionGuard`; child guards unchanged | Phase 3 | None planned | Low | | `/teach/classes` | `/teach/classes` | teacher, instructor, org_admin | No redirect | `HomeSessionGuard` plus `RoleGuard` section roles | Phase 3 | None planned | Low | | `/teach/classes/:id` | `/teach/classes/:id` | teacher, instructor, org_admin | No redirect | `HomeSessionGuard` plus `RoleGuard` section roles; frontend verifies route section is in authorized section list | Phase 3 | None planned | Low | @@ -22,9 +22,13 @@ Phase 1 preserves route compatibility and establishes canonical role destination | `/workspace` | `/workspace` | content_admin | No redirect | `HomeSessionGuard` plus existing role guards | Phase 1 | None planned | Medium | | `/workspace/learners` | `/workspace/learners` | org_admin | No redirect | Existing creator-role guard | Phase 1 | Replace only if organization home becomes a dedicated route | Medium | | `/workspace/commercial` | `/workspace/commercial` with visible label `Community` | admin/content_admin/org_admin/teacher/instructor via existing creator role set | No redirect | Existing creator-role guard | Phase 1 | Add alias and deprecation only after community surface is redesigned | Medium | -| `/home/admin/users` | `/home/admin/users` | admin | No redirect | Existing `RoleGuard` admin-only | Phase 1 | Dedicated Admin IA route exists and old path is aliased | High | -| `/home/admin/courses` | `/home/admin/courses` | admin | No redirect | Existing `RoleGuard` admin-only | Phase 1 | Dedicated Admin IA route exists and old path is aliased | High | -| `/home/admin/badges` | `/home/admin/badges` | admin | No redirect | Existing `RoleGuard` admin-only | Phase 1 | Dedicated Admin IA route exists and old path is aliased | High | +| `/home/admin/users` and `/workspace/learners/users` | `/admin/users` | admin | Legacy routes remain; canonical navigation uses `/admin/users` | `HomeSessionGuard` plus `RoleGuard` admin-only | Phase 4 | Add explicit redirects after bookmark/usage validation | Medium | +| No legacy detail | `/admin/users/:userId` | admin | New route | `HomeSessionGuard` plus `RoleGuard` admin-only; backend exact admin check | Phase 4 | None planned | Low | +| No platform directory route | `/admin/organizations` and `:organizationId` | admin, super_admin | New scoped read-only routes | `HomeSessionGuard` plus `RoleGuard`; API returns current-account memberships only | Phase 4 | Replace only after platform directory API is approved | Low | +| `/home/admin/courses` and `/workspace/products/manage` | `/admin/courses` | admin | Legacy routes remain; canonical navigation uses `/admin/courses` | `HomeSessionGuard` plus `RoleGuard` admin-only | Phase 4 | Add explicit redirects after bookmark/usage validation | Medium | +| No legacy oversight detail | `/admin/courses/:courseId` | admin | New route | `HomeSessionGuard` plus `RoleGuard` admin-only | Phase 4 | None planned | Low | +| `/home/admin/badges` and `/workspace/settings/badges` | `/admin/badges` | admin; super_admin read-only compatible | Legacy routes remain admin-only; canonical route permits supported read compatibility | `HomeSessionGuard` plus `RoleGuard`; writes remain backend admin-only | Phase 4 | Add explicit redirects after bookmark/usage validation | Medium | +| `/workspace/analytics` | `/admin/reports` for platform counts; workspace route remains Studio analytics | admin | No redirect because definitions differ | `HomeSessionGuard` plus `RoleGuard` admin-only | Phase 4 | None; keep reporting domains separate | Low | | `/home/sections` | `/teach/classes` | teacher, instructor, org_admin | No redirect | Existing `RoleGuard` section roles | Phase 3 alias added | Remove only after teacher bookmarks and smoke tests move to `/teach/classes` | High | | `/home/sections/:id` | `/teach/classes/:id` | teacher, instructor, org_admin | No redirect | Existing `RoleGuard` section roles | Phase 3 alias added | Remove only after class detail deep links have compatibility redirects | High | | `/home/courses` | `/home/courses` | teacher, instructor, student transitional | No redirect | Existing child route, no added guard | Phase 1 | Course library split is implemented and tested | Medium | @@ -40,8 +44,8 @@ Phase 1 preserves route compatibility and establishes canonical role destination - `instructor`: `/teach` - `content_admin`: `/workspace` - `org_admin`: `/workspace/learners` -- `admin`: `/home` -- `super_admin`: `/home` +- `admin`: `/admin` +- `super_admin`: `/admin` with partial compatibility state ## Route Compatibility Notes @@ -53,3 +57,5 @@ Phase 1 preserves route compatibility and establishes canonical role destination - No dedicated unit route was added because existing APIs do not expose unit-level student progress or direct-link authorization. - Phase 3 adds canonical Teach routes without deleting `/home/sections`, `/home/sections/:id`, or `/workspace/learners/cohorts` compatibility routes. - Teacher course preview intentionally avoids `/api/start-course`; it uses `/api/courses/{id}` only. +- Phase 4 does not add moderation or platform-settings routes because no safely authorized capability exists. +- `super_admin` is not treated as inherited `admin`; unsupported child routes stay guarded and hidden. diff --git a/docs/ux-overhaul/shared-component-status.md b/docs/ux-overhaul/shared-component-status.md index a7dea39..cd54b90 100644 --- a/docs/ux-overhaul/shared-component-status.md +++ b/docs/ux-overhaul/shared-component-status.md @@ -45,11 +45,23 @@ - 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. +## Added Or Migrated In Admin Phase + +| Component/screen | Path | Status | Notes | +| --- | --- | --- | --- | +| Admin production layout | `frontend/src/styles/_admin-production.scss` | Production active | Shared Admin page, panel, filter, semantic table/mobile record, status, action, and detail patterns. | +| Admin overview | `frontend/src/app/pages/admin-overview/` | Production active | Independent partial-failure counts, limitations, and supported shortcuts. | +| Admin users/detail | `frontend/src/app/pages/admin-users/`, `admin-user-detail/` | Production active | Privacy-minimized responsive list, confirmed role changes and deletion. | +| Admin organizations | `frontend/src/app/pages/admin-organizations/` | Production active, read-only | Explicit current-account membership scope and neutral deep-link handling. | +| Admin courses | `frontend/src/app/pages/admin-courses/` | Production active | Oversight/governance only; confirmed delete; no authoring controls. | +| Admin badges | `frontend/src/app/pages/admin-badges/` | Production active | Create/upload for admin, read-only compatibility, accessible images. | +| Admin reports | `frontend/src/app/pages/admin-reports/` | Production active | Defined current aggregate counts only. | +| Confirmation dialog | `frontend/src/app/components/echo-confirmation-dialog/` | Adopted in Admin | User role, user delete, and course delete consequences use shared focus-safe dialog. | + ## Required Future Adoption -- Admin user deletes/role changes. -- Course archive/delete/publish actions. -- Badge create/update/delete flows. +- Course archive/publish actions if supported outside Studio. +- Badge update/delete flows if APIs are approved. - Organization invite revocation. - Access grant revoke flows. - Product publish/review transitions. diff --git a/frontend/package.json b/frontend/package.json index adccd93..6cfd95f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", - "smoke:demo:student": "playwright test tests/demo/student-flagship-smoke.spec.ts" + "smoke:demo:student": "playwright test tests/demo/student-flagship-smoke.spec.ts", + "smoke:demo:admin": "playwright test tests/demo/admin-platform-smoke.spec.ts" }, "private": true, "dependencies": { diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 352fc5b..f7731d3 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ timeout: 10_000, }, fullyParallel: false, + workers: 1, forbidOnly: !!process.env['CI'], retries: process.env['CI'] ? 1 : 0, reporter: [['list']], diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts index 1b28b3d..d7220b7 100644 --- a/frontend/src/app/app.routes.spec.ts +++ b/frontend/src/app/app.routes.spec.ts @@ -15,6 +15,9 @@ import { SectionDetailComponent } from './pages/section-detail/section-detail.co import { TeacherCurriculumComponent } from './pages/teacher-curriculum/teacher-curriculum.component'; import { TeacherCoursePreviewComponent } from './pages/teacher-curriculum/teacher-course-preview.component'; import { TeacherLearnerDetailComponent } from './pages/teacher-learner-detail/teacher-learner-detail.component'; +import { AdminOverviewComponent } from './pages/admin-overview/admin-overview.component'; +import { AdminUsersComponent } from './pages/admin-users/admin-users.component'; +import { AdminOrganizationsComponent } from './pages/admin-organizations/admin-organizations.component'; function findRoute(routeList: Routes, path: string): Route | undefined { const exactRoute = routeList.find(candidate => candidate.path === path); @@ -120,6 +123,30 @@ describe('app routes', () => { expect(findRoute(routes, 'teach/curriculum')?.canActivate).toContain(RoleGuard); }); + it('adds guarded canonical Admin routes while preserving legacy aliases', () => { + [ + 'admin', + 'admin/users', + 'admin/users/:userId', + 'admin/organizations', + 'admin/organizations/:organizationId', + 'admin/courses', + 'admin/courses/:courseId', + 'admin/badges', + 'admin/reports', + 'home/admin/users', + 'home/admin/courses', + 'home/admin/badges', + ].forEach(path => expect(findRoute(routes, path)).withContext(path).toBeTruthy()); + + expect(findRoute(routes, 'admin/')?.component).toBe(AdminOverviewComponent); + expect(findRoute(routes, 'admin/users')?.component).toBe(AdminUsersComponent); + expect(findRoute(routes, 'admin/organizations')?.component).toBe(AdminOrganizationsComponent); + expect(findRoute(routes, 'admin/users')?.canActivate).toContain(RoleGuard); + expect(findRoute(routes, 'admin/users')?.data?.['roles']).toEqual(['admin']); + expect(findRoute(routes, 'admin/organizations')?.data?.['roles']).toEqual(['admin', 'super_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 303aea7..7e1d717 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -45,6 +45,10 @@ import { CommercialDashboardComponent } from './pages/commercial-dashboard/comme import { TeacherCurriculumComponent } from './pages/teacher-curriculum/teacher-curriculum.component'; import { TeacherCoursePreviewComponent } from './pages/teacher-curriculum/teacher-course-preview.component'; import { TeacherLearnerDetailComponent } from './pages/teacher-learner-detail/teacher-learner-detail.component'; +import { AdminOverviewComponent } from './pages/admin-overview/admin-overview.component'; +import { AdminUserDetailComponent } from './pages/admin-user-detail/admin-user-detail.component'; +import { AdminOrganizationsComponent } from './pages/admin-organizations/admin-organizations.component'; +import { AdminReportsComponent } from './pages/admin-reports/admin-reports.component'; const creatorRoles = ['admin', 'teacher', 'content_admin', 'org_admin', 'instructor']; const studioRoles = ['content_admin', 'org_admin']; @@ -58,6 +62,22 @@ export const routes: Routes = [ { path: 'registration', component: RegistrationComponent }, { path: 'onboarding/organization', component: OnboardingOrganizationComponent }, { path: 'access-denied', component: AccessDeniedComponent }, + { + path: 'admin', + component: HomeComponent, + canActivate: [HomeSessionGuard], + children: [ + { path: '', component: AdminOverviewComponent, canActivate: [RoleGuard], data: { roles: ['admin', 'super_admin'] } }, + { path: 'users', component: AdminUsersComponent, canActivate: [RoleGuard], data: { roles: ['admin'] } }, + { path: 'users/:userId', component: AdminUserDetailComponent, canActivate: [RoleGuard], data: { roles: ['admin'] } }, + { path: 'organizations', component: AdminOrganizationsComponent, canActivate: [RoleGuard], data: { roles: ['admin', 'super_admin'] } }, + { path: 'organizations/:organizationId', component: AdminOrganizationsComponent, canActivate: [RoleGuard], data: { roles: ['admin', 'super_admin'] } }, + { path: 'courses', component: AdminCoursesComponent, canActivate: [RoleGuard], data: { roles: ['admin'] } }, + { path: 'courses/:courseId', component: AdminCoursesComponent, canActivate: [RoleGuard], data: { roles: ['admin'] } }, + { path: 'badges', component: AdminBadgesComponent, canActivate: [RoleGuard], data: { roles: ['admin', 'super_admin'] } }, + { path: 'reports', component: AdminReportsComponent, canActivate: [RoleGuard], data: { roles: ['admin'] } }, + ], + }, { path: 'teach', component: HomeComponent, diff --git a/frontend/src/app/models/course.ts b/frontend/src/app/models/course.ts index c1c6594..a50a538 100644 --- a/frontend/src/app/models/course.ts +++ b/frontend/src/app/models/course.ts @@ -16,5 +16,9 @@ export interface Course { /** Optional number of ratings */ ratingCount?: number; units?: Unit[]; - created_at: Date; + created_at?: Date; + created_by?: string | null; + organization_id?: string | null; + revision_status?: string; + published_at?: string | null; } diff --git a/frontend/src/app/pages/admin-badges/admin-badges.component.html b/frontend/src/app/pages/admin-badges/admin-badges.component.html index 21d6c30..6c50a77 100644 --- a/frontend/src/app/pages/admin-badges/admin-badges.component.html +++ b/frontend/src/app/pages/admin-badges/admin-badges.component.html @@ -1,36 +1,19 @@ -
-
-
-

Admin

-

Manage Badges

-

Create and preview recognition moments for students.

-
- Engagement -
-
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
- -
-

{{ badge.title }}

-

{{ badge.description }}

-
-
-
-
+
+

Admin / Badges

Badge administration

Create supported recognition badges and review the current catalog. Editing, deleting, archiving, and criteria management are not available from current APIs.

+ + + + +

Create badge

+
Enter a badge name.
Optional PNG, JPEG, or WebP image.
+

{{ submitError }}

+
+

Read-only badge catalog

The current backend permits badge writes only for the admin role. No write controls are shown for this role.

+ + +

{{ filteredBadges.length }} {{ filteredBadges.length === 1 ? 'badge' : 'badges' }} shown

+ + +

{{ badge.title }}

{{ badge.description || 'No description provided.' }}

Created {{ badge.created_at | date:'mediumDate' }}

+
+
diff --git a/frontend/src/app/pages/admin-badges/admin-badges.component.scss b/frontend/src/app/pages/admin-badges/admin-badges.component.scss index 1a0f530..a15fe64 100644 --- a/frontend/src/app/pages/admin-badges/admin-badges.component.scss +++ b/frontend/src/app/pages/admin-badges/admin-badges.component.scss @@ -1 +1,4 @@ /* Add any custom styles for badges admin here */ +@use '../../../styles/admin-production'; + +img { border-radius: var(--ee-radius-card-default); object-fit: cover; } diff --git a/frontend/src/app/pages/admin-badges/admin-badges.component.spec.ts b/frontend/src/app/pages/admin-badges/admin-badges.component.spec.ts new file mode 100644 index 0000000..4e9d670 --- /dev/null +++ b/frontend/src/app/pages/admin-badges/admin-badges.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { AdminBadgesComponent } from './admin-badges.component'; +import { BadgesService } from '../../services/badges.service'; +import { PermissionsService } from '../../services/permissions.service'; + +describe('AdminBadgesComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [AdminBadgesComponent], providers: [ + { provide: BadgesService, useValue: { getBadges: () => of([{ id: 'badge-1', title: 'Community Scholar', description: 'Completed the pathway', image_url: '/badge.png', created_at: new Date() }]) } }, + { provide: PermissionsService, useValue: { user$: of({ role: 'admin' }) } }, + ] }).compileComponents(); + fixture = TestBed.createComponent(AdminBadgesComponent); fixture.detectChanges(); + }); + + it('provides an accessible badge image name and supported create control', () => { + const image = fixture.nativeElement.querySelector('img') as HTMLImageElement; + expect(image.alt).toBe('Community Scholar badge'); expect(fixture.nativeElement.textContent).toContain('Create badge'); + }); +}); diff --git a/frontend/src/app/pages/admin-badges/admin-badges.component.ts b/frontend/src/app/pages/admin-badges/admin-badges.component.ts index 794d770..e1b140e 100644 --- a/frontend/src/app/pages/admin-badges/admin-badges.component.ts +++ b/frontend/src/app/pages/admin-badges/admin-badges.component.ts @@ -1,63 +1,60 @@ -import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { BadgesService } from '../../services/badges.service'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; +import { Subscription, switchMap } from 'rxjs'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; import { Badge } from '../../models/badge'; +import { BadgesService } from '../../services/badges.service'; +import { PermissionsService } from '../../services/permissions.service'; @Component({ selector: 'admin-badges-page', standalone: true, - imports: [CommonModule, ReactiveFormsModule], + imports: [CommonModule, FormsModule, ReactiveFormsModule, EchoLoadingStateComponent, EchoStatePanelComponent], templateUrl: './admin-badges.component.html', styleUrl: './admin-badges.component.scss' }) -export class AdminBadgesComponent implements OnInit { +export class AdminBadgesComponent implements OnInit, OnDestroy { badges: Badge[] = []; badgeForm: FormGroup; selectedFile: File | null = null; + search = ''; + role = ''; + loading = true; + failed = false; + submitting = false; + submitError = ''; + successMessage = ''; + private readonly subscriptions = new Subscription(); - constructor(private badgesService: BadgesService, private fb: FormBuilder) { - this.badgeForm = this.fb.group({ - title: ['', Validators.required], - description: [''], - image_url: [''] - }); + constructor(private readonly badgesService: BadgesService, fb: FormBuilder, permissions: PermissionsService) { + this.badgeForm = fb.group({ title: ['', [Validators.required, Validators.maxLength(120)]], description: ['', Validators.maxLength(500)], image_url: [''] }); + this.subscriptions.add(permissions.user$.subscribe(user => this.role = user?.role ?? '')); } - ngOnInit(): void { - this.loadBadges(); - } - - loadBadges() { - this.badgesService.getBadges().subscribe(badges => (this.badges = badges)); - } + ngOnInit(): void { this.loadBadges(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + get canCreate(): boolean { return this.role === 'admin'; } + get filteredBadges(): Badge[] { const q = this.search.trim().toLowerCase(); return this.badges.filter(badge => !q || badge.title.toLowerCase().includes(q)); } - onFileSelected(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - this.selectedFile = input.files[0]; - } + loadBadges(): void { + this.loading = true; this.failed = false; + this.subscriptions.add(this.badgesService.getBadges().subscribe({ next: badges => { this.badges = badges; this.loading = false; }, error: () => { this.failed = true; this.loading = false; } })); } - onSubmit() { - if (this.badgeForm.invalid) return; - const finalize = (url?: string) => { - if (url) { - this.badgeForm.patchValue({ image_url: url }); - } - this.badgesService.createBadge(this.badgeForm.value).subscribe(badge => { - this.badges.push(badge); - this.badgeForm.reset(); - this.selectedFile = null; - }); - }; + onFileSelected(event: Event): void { this.selectedFile = (event.target as HTMLInputElement).files?.[0] ?? null; } - if (this.selectedFile) { - this.badgesService.uploadBadgeImage(this.selectedFile).subscribe(res => { - finalize(res.file_path); - }); - } else { - finalize(); - } + onSubmit(): void { + if (!this.canCreate || this.badgeForm.invalid || this.submitting) { this.badgeForm.markAllAsTouched(); return; } + this.submitting = true; this.submitError = ''; this.successMessage = ''; + const create = (imageUrl?: string) => this.badgesService.createBadge({ ...this.badgeForm.value, image_url: imageUrl || this.badgeForm.value.image_url || undefined }); + const request = this.selectedFile ? this.badgesService.uploadBadgeImage(this.selectedFile).pipe(switchMap(response => create(response.file_path))) : create(); + this.subscriptions.add(request.subscribe({ + next: badge => { this.badges = [...this.badges, badge]; this.badgeForm.reset(); this.selectedFile = null; this.submitting = false; this.successMessage = `${badge.title} was created.`; }, + error: () => { this.submitting = false; this.submitError = 'The badge was not created. Review the fields and try again.'; }, + })); } } diff --git a/frontend/src/app/pages/admin-courses/admin-courses.component.html b/frontend/src/app/pages/admin-courses/admin-courses.component.html index 8e5a940..d1cf1c1 100644 --- a/frontend/src/app/pages/admin-courses/admin-courses.component.html +++ b/frontend/src/app/pages/admin-courses/admin-courses.component.html @@ -1,26 +1,24 @@ -
-
-
-

Products

-

Course-backed products

-
- -
- - - - - - - - - - - - - -
TitleActions
{{ course.title }} - - -
-
+
+ Back to courses +

Admin / Courses

{{ detailMode && selected ? selected.title : 'Course oversight' }}

Inspect curriculum availability and governance without entering content-authoring workflows.

+ + + + +

{{ filteredCourses.length }} {{ filteredCourses.length === 1 ? 'course' : 'courses' }} shown

+ + +

Courses

Courses available for platform oversight
TitleStatusOrganization contextAction
{{ course.title }}{{ label(status(course)) | titlecase }}{{ course.organization_id ? 'Organization assigned' : 'Not provided' }}Review oversight
+
{{ course.title }}{{ label(status(course)) | titlecase }}{{ course.organization_id ? 'Organization assigned' : 'Organization not provided' }}Review oversight
+
+
+ + +

Course identity

Title
{{ selected.title }}
Status
{{ label(status(selected)) | titlecase }}
Organization context
{{ selected.organization_id ? 'Assigned' : 'Not provided' }}
Units
{{ selected.units?.length || 0 }}
Published
{{ selected.published_at ? (selected.published_at | date:'mediumDate') : 'Not available' }}
Authoring access
Not provided in Admin

{{ selected.description || 'No course description is available.' }}

+ +

Governance status

Publish readiness
{{ governance.publish_readiness.is_ready ? 'Ready' : 'Needs review' }} ({{ governance.publish_readiness.blocking_issue_count }} blocking)
Safe publish validation
{{ governance.safe_publish_validation.is_safe ? 'Safe' : 'Not safe' }}
Lineage
{{ governance.lineage_safety_visibility.is_coherent ? 'Coherent' : 'Needs review' }}
Assessment evidence
{{ governance.competency_evidence_integrity.is_valid ? 'Valid' : 'Needs review' }}
+

High-impact actions

Deleting this course permanently removes the current course record and can disrupt learning access. The current API does not provide archive or restore.

+
+ + +
diff --git a/frontend/src/app/pages/admin-courses/admin-courses.component.scss b/frontend/src/app/pages/admin-courses/admin-courses.component.scss index f696aed..b5b9873 100644 --- a/frontend/src/app/pages/admin-courses/admin-courses.component.scss +++ b/frontend/src/app/pages/admin-courses/admin-courses.component.scss @@ -1 +1,2 @@ /* empty styles */ +@use '../../../styles/admin-production'; diff --git a/frontend/src/app/pages/admin-courses/admin-courses.component.spec.ts b/frontend/src/app/pages/admin-courses/admin-courses.component.spec.ts new file mode 100644 index 0000000..1d7f2cc --- /dev/null +++ b/frontend/src/app/pages/admin-courses/admin-courses.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; +import { AdminCoursesComponent } from './admin-courses.component'; +import { CoursesService } from '../../services/courses.service'; + +describe('AdminCoursesComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [RouterTestingModule, AdminCoursesComponent], providers: [ + { provide: CoursesService, useValue: { getCourses: () => of([{ id: 'course-1', title: 'World History', description: 'History course' }]), deleteCourse: () => of({}) } }, + { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({}) } } }, + ] }).compileComponents(); + fixture = TestBed.createComponent(AdminCoursesComponent); fixture.detectChanges(); + }); + + it('renders oversight without content-authoring controls when metadata is sparse', () => { + const text = fixture.nativeElement.textContent; + expect(text).toContain('World History'); expect(text).toContain('Status Unavailable'); + expect(text).not.toContain('Create course'); expect(text).not.toContain('Edit course'); + }); +}); diff --git a/frontend/src/app/pages/admin-courses/admin-courses.component.ts b/frontend/src/app/pages/admin-courses/admin-courses.component.ts index 76a04b2..b1688a3 100644 --- a/frontend/src/app/pages/admin-courses/admin-courses.component.ts +++ b/frontend/src/app/pages/admin-courses/admin-courses.component.ts @@ -1,38 +1,68 @@ -import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { CoursesService } from '../../services/courses.service'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { EchoConfirmationDialogComponent } from '../../components/echo-confirmation-dialog/echo-confirmation-dialog.component'; +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; import { Course } from '../../models/course'; -import { Router } from '@angular/router'; +import { CourseGovernanceSummary } from '../../models/course-publish-readiness.model'; +import { CoursesService } from '../../services/courses.service'; @Component({ selector: 'admin-courses-page', standalone: true, - imports: [CommonModule], + imports: [CommonModule, FormsModule, RouterLink, EchoConfirmationDialogComponent, EchoLoadingStateComponent, EchoStatePanelComponent], templateUrl: './admin-courses.component.html', styleUrl: './admin-courses.component.scss' }) -export class AdminCoursesComponent implements OnInit { +export class AdminCoursesComponent implements OnInit, OnDestroy { courses: Course[] = []; + selected?: Course; + governance?: CourseGovernanceSummary; + detailMode = false; + search = ''; + loading = true; + failed = false; + governanceFailed = false; + pendingDelete?: Course; + deleting = false; + deleteError = ''; + private readonly subscriptions = new Subscription(); - constructor(private coursesService: CoursesService, private router: Router) {} + constructor(private readonly coursesService: CoursesService, private readonly route: ActivatedRoute, private readonly router: Router) {} + ngOnInit(): void { this.detailMode = this.route.snapshot.paramMap.has('courseId'); this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + get filteredCourses(): Course[] { const q = this.search.trim().toLowerCase(); return this.courses.filter(course => !q || course.title.toLowerCase().includes(q)); } - ngOnInit(): void { - this.coursesService.getCourses().subscribe(courses => { - this.courses = courses; - }); + load(): void { + this.loading = true; this.failed = false; + const id = this.route.snapshot.paramMap.get('courseId'); + if (id) { + this.subscriptions.add(this.coursesService.getCourseById(id).subscribe({ next: course => { this.selected = course as unknown as Course; this.loading = false; this.loadGovernance(id); }, error: () => { this.failed = true; this.loading = false; } })); + return; + } + this.subscriptions.add(this.coursesService.getCourses().subscribe({ next: courses => { this.courses = courses; this.loading = false; }, error: () => { this.failed = true; this.loading = false; } })); } - onManageCourse(courseId: string) { - this.router.navigate(['/home/courses', courseId, 'edit']); + loadGovernance(courseId: string): void { + this.governanceFailed = false; + this.subscriptions.add(this.coursesService.getCourseGovernanceSummary(courseId).subscribe({ next: value => this.governance = value, error: () => this.governanceFailed = true })); } - goToCreateCourse() { - this.router.navigate(['/home/courses/new']); + requestDelete(course: Course): void { this.deleteError = ''; this.pendingDelete = course; } + cancelDelete(): void { if (!this.deleting) this.pendingDelete = undefined; } + confirmDelete(): void { + if (!this.pendingDelete || this.deleting) return; + const course = this.pendingDelete; this.deleting = true; this.deleteError = ''; + this.subscriptions.add(this.coursesService.deleteCourse(course.id).subscribe({ + next: () => { this.deleting = false; this.pendingDelete = undefined; this.router.navigate(['/admin/courses']); }, + error: () => { this.deleting = false; this.deleteError = 'The course was not deleted. No local changes were made.'; }, + })); } - onDeleteCourse(courseId: string) { - this.coursesService.deleteCourse(courseId).subscribe(() => { - this.courses = this.courses.filter(c => c.id !== courseId); - }); - } + status(course: Course): string { return course.revision_status || (course.published_at ? 'published' : 'status unavailable'); } + label(value: string): string { return value.replace(/_/g, ' '); } } diff --git a/frontend/src/app/pages/admin-organizations/admin-organizations.component.html b/frontend/src/app/pages/admin-organizations/admin-organizations.component.html new file mode 100644 index 0000000..fbc4bce --- /dev/null +++ b/frontend/src/app/pages/admin-organizations/admin-organizations.component.html @@ -0,0 +1,20 @@ +
+ Back to organizations +

Admin / Organizations

{{ detailMode && selected ? selected.name : 'Organizations' }}

Platform visibility is limited to organizations associated with the current account. Organization self-service remains in the Organization area.

+ + + + + +

Scoped organization visibility

The current API does not provide a platform-wide organization directory, member counts, administrator counts, invitations, sections, or organization lifecycle status. These values are not inferred.

+ +
+

{{ organization.name }}

{{ label(organization.type) }}

{{ organization.country || 'Country not provided' }} · {{ organization.timezone }}

Your membership: {{ label(organization.role) | titlecase }}

Review details
+
+
+ + +

Organization context

Name
{{ selected.name }}
Type
{{ label(selected.type) | titlecase }}
Country
{{ selected.country || 'Not provided' }}
Timezone
{{ selected.timezone || 'Not provided' }}
Created
{{ selected.created_at ? (selected.created_at | date:'mediumDate') : 'Not available' }}
Your organization role
{{ label(selected.role) | titlecase }}
+

Oversight availability

Members, organization administrators, invitations, classes, assigned learning, and status are read-only unavailable because no platform-scoped read API exposes them. No organization-changing actions are shown.

+
+
diff --git a/frontend/src/app/pages/admin-organizations/admin-organizations.component.scss b/frontend/src/app/pages/admin-organizations/admin-organizations.component.scss new file mode 100644 index 0000000..b8f5cfa --- /dev/null +++ b/frontend/src/app/pages/admin-organizations/admin-organizations.component.scss @@ -0,0 +1 @@ +@use '../../../styles/admin-production'; diff --git a/frontend/src/app/pages/admin-organizations/admin-organizations.component.spec.ts b/frontend/src/app/pages/admin-organizations/admin-organizations.component.spec.ts new file mode 100644 index 0000000..a11f183 --- /dev/null +++ b/frontend/src/app/pages/admin-organizations/admin-organizations.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; +import { AdminOrganizationsComponent } from './admin-organizations.component'; +import { OrganizationService } from '../../services/organization.service'; + +describe('AdminOrganizationsComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [RouterTestingModule, AdminOrganizationsComponent], providers: [ + { provide: OrganizationService, useValue: { refreshOrganizations: () => of([{ id: 'org-1', name: 'Echo Academy', type: 'school', role: 'org_admin' }]) } }, + { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({}) } } }, + ] }).compileComponents(); + fixture = TestBed.createComponent(AdminOrganizationsComponent); fixture.detectChanges(); + }); + + it('labels organization visibility as account-scoped and does not invent member counts', () => { + const text = fixture.nativeElement.textContent; + expect(text).toContain('Scoped organization visibility'); expect(text).toContain('Echo Academy'); expect(text).not.toContain('Member count'); + }); +}); diff --git a/frontend/src/app/pages/admin-organizations/admin-organizations.component.ts b/frontend/src/app/pages/admin-organizations/admin-organizations.component.ts new file mode 100644 index 0000000..3681540 --- /dev/null +++ b/frontend/src/app/pages/admin-organizations/admin-organizations.component.ts @@ -0,0 +1,44 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { Organization } from '../../models/organization'; +import { OrganizationService } from '../../services/organization.service'; + +@Component({ + selector: 'app-admin-organizations', + standalone: true, + imports: [CommonModule, RouterLink, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './admin-organizations.component.html', + styleUrl: './admin-organizations.component.scss', +}) +export class AdminOrganizationsComponent implements OnInit, OnDestroy { + organizations: Organization[] = []; + selected?: Organization; + detailMode = false; + loading = true; + failed = false; + private readonly subscriptions = new Subscription(); + + constructor(private readonly route: ActivatedRoute, private readonly organizationService: OrganizationService) {} + ngOnInit(): void { this.detailMode = this.route.snapshot.paramMap.has('organizationId'); this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + + load(): void { + this.loading = true; this.failed = false; + this.subscriptions.add(this.organizationService.refreshOrganizations().subscribe({ + next: organizations => { + this.organizations = organizations; + const id = this.route.snapshot.paramMap.get('organizationId'); + this.selected = id ? organizations.find(org => org.id === id) : undefined; + this.loading = false; + }, + error: () => { this.failed = true; this.loading = false; }, + })); + } + + label(value?: string): string { return value ? value.replace(/_/g, ' ') : 'Not available'; } +} diff --git a/frontend/src/app/pages/admin-overview/admin-overview.component.html b/frontend/src/app/pages/admin-overview/admin-overview.component.html new file mode 100644 index 0000000..27a3259 --- /dev/null +++ b/frontend/src/app/pages/admin-overview/admin-overview.component.html @@ -0,0 +1,58 @@ +
+
+
+

Platform administration

+

Admin overview

+

Review platform access, learning resources, and supported operational status.

+
+
+ + + + + + + +
+

Needs attention

+
+

No verified platform attention queue is available.

+

Review users, course governance, and organizations directly. Community reports and moderation actions are not presented because the current APIs do not provide authorized, scoped queues.

+
+
+ +
+

Platform summary

+
+

{{ counts.users }}

Total users

+

{{ overview.totals.students }}

Students

+

{{ overview.totals.teachers }}

Teachers

+

{{ counts.organizations }}

Organizations available to this account

+

{{ counts.courses }}

Courses

+

{{ counts.badges }}

Badges

+
+
+ +
+

Administrative shortcuts

+ +
+
diff --git a/frontend/src/app/pages/admin-overview/admin-overview.component.scss b/frontend/src/app/pages/admin-overview/admin-overview.component.scss new file mode 100644 index 0000000..b8f5cfa --- /dev/null +++ b/frontend/src/app/pages/admin-overview/admin-overview.component.scss @@ -0,0 +1 @@ +@use '../../../styles/admin-production'; diff --git a/frontend/src/app/pages/admin-overview/admin-overview.component.spec.ts b/frontend/src/app/pages/admin-overview/admin-overview.component.spec.ts new file mode 100644 index 0000000..55292ff --- /dev/null +++ b/frontend/src/app/pages/admin-overview/admin-overview.component.spec.ts @@ -0,0 +1,59 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BehaviorSubject, of, throwError } from 'rxjs'; + +import { AdminOverviewComponent } from './admin-overview.component'; +import { AnalyticsService } from '../../services/analytics.service'; +import { UsersService } from '../../services/users.service'; +import { CoursesService } from '../../services/courses.service'; +import { OrganizationService } from '../../services/organization.service'; +import { BadgesService } from '../../services/badges.service'; +import { PermissionsService } from '../../services/permissions.service'; + +describe('AdminOverviewComponent', () => { + let fixture: ComponentFixture; + const analytics = jasmine.createSpyObj('AnalyticsService', ['getAdminOverview']); + const users = jasmine.createSpyObj('UsersService', ['getUsers']); + const courses = jasmine.createSpyObj('CoursesService', ['getCourses']); + const organizations = jasmine.createSpyObj('OrganizationService', ['refreshOrganizations']); + const badges = jasmine.createSpyObj('BadgesService', ['getBadges']); + const role$ = new BehaviorSubject({ role: 'admin', user_id: 'admin-1' }); + const permissions = { user$: role$.asObservable() }; + + beforeEach(async () => { + role$.next({ role: 'admin', user_id: 'admin-1' }); + analytics.getAdminOverview.calls.reset(); users.getUsers.calls.reset(); courses.getCourses.calls.reset(); + analytics.getAdminOverview.and.returnValue(of({ totals: { students: 2, teachers: 1, courses: 3, active_students: 2, total_enrollments: 2, pending_enrollments: 1 }, progress: { lessons_completed: 4, units_completed: 2, courses_completed: 1, course_completion_rate: 50 } })); + users.getUsers.and.returnValue(of([{ id: '1' }, { id: '2' }])); + courses.getCourses.and.returnValue(of([{ id: 'course-1' }])); + organizations.refreshOrganizations.and.returnValue(of([{ id: 'org-1' }])); + badges.getBadges.and.returnValue(of([{ id: 'badge-1' }])); + await TestBed.configureTestingModule({ imports: [RouterTestingModule, AdminOverviewComponent], providers: [ + { provide: AnalyticsService, useValue: analytics }, { provide: UsersService, useValue: users }, + { provide: CoursesService, useValue: courses }, { provide: OrganizationService, useValue: organizations }, + { provide: BadgesService, useValue: badges }, { provide: PermissionsService, useValue: permissions }, + ] }).compileComponents(); + }); + + it('renders supported counts and no invented attention queue', () => { + fixture = TestBed.createComponent(AdminOverviewComponent); fixture.detectChanges(); + const text = fixture.nativeElement.textContent; + expect(text).toContain('Total users'); expect(text).toContain('Organizations available to this account'); + expect(text).toContain('No verified platform attention queue is available'); + }); + + it('keeps successful data visible after a partial API failure', () => { + users.getUsers.and.returnValue(throwError(() => new Error('failed'))); + fixture = TestBed.createComponent(AdminOverviewComponent); fixture.detectChanges(); + const text = fixture.nativeElement.textContent; + expect(text).toContain('Some platform information could not be loaded'); + expect(text).toContain('Courses'); + }); + + it('shows compatible super-admin limitations without calling admin-only APIs', () => { + role$.next({ role: 'super_admin', user_id: 'super-1' }); + fixture = TestBed.createComponent(AdminOverviewComponent); fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Limited super-admin compatibility'); + expect(analytics.getAdminOverview).not.toHaveBeenCalled(); expect(users.getUsers).not.toHaveBeenCalled(); expect(courses.getCourses).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/pages/admin-overview/admin-overview.component.ts b/frontend/src/app/pages/admin-overview/admin-overview.component.ts new file mode 100644 index 0000000..8dbf8fa --- /dev/null +++ b/frontend/src/app/pages/admin-overview/admin-overview.component.ts @@ -0,0 +1,68 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { AdminOverviewResponse, AnalyticsService } from '../../services/analytics.service'; +import { BadgesService } from '../../services/badges.service'; +import { CoursesService } from '../../services/courses.service'; +import { OrganizationService } from '../../services/organization.service'; +import { PermissionsService } from '../../services/permissions.service'; +import { UsersService } from '../../services/users.service'; + +type SourceName = 'overview' | 'users' | 'organizations' | 'courses' | 'badges'; + +@Component({ + selector: 'app-admin-overview', + standalone: true, + imports: [CommonModule, RouterLink, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './admin-overview.component.html', + styleUrl: './admin-overview.component.scss', +}) +export class AdminOverviewComponent implements OnInit, OnDestroy { + role = ''; + loading = true; + overview?: AdminOverviewResponse; + counts = { users: 0, organizations: 0, courses: 0, badges: 0 }; + failedSources = new Set(); + private readonly subscriptions = new Subscription(); + + constructor( + private readonly permissions: PermissionsService, + private readonly analytics: AnalyticsService, + private readonly users: UsersService, + private readonly organizations: OrganizationService, + private readonly courses: CoursesService, + private readonly badges: BadgesService, + ) {} + + ngOnInit(): void { + this.subscriptions.add(this.permissions.user$.subscribe(user => { + if (!user || this.role) return; + this.role = user.role; + this.load(); + })); + } + + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + + get isAdmin(): boolean { return this.role === 'admin'; } + + load(): void { + this.loading = true; + this.failedSources.clear(); + let pending = this.isAdmin ? 5 : 2; + const done = () => { pending -= 1; this.loading = pending > 0; }; + const fail = (source: SourceName) => { this.failedSources.add(source); done(); }; + + if (this.isAdmin) { + this.subscriptions.add(this.analytics.getAdminOverview().subscribe({ next: value => { this.overview = value; done(); }, error: () => fail('overview') })); + this.subscriptions.add(this.users.getUsers().subscribe({ next: value => { this.counts.users = value.length; done(); }, error: () => fail('users') })); + this.subscriptions.add(this.courses.getCourses().subscribe({ next: value => { this.counts.courses = value.length; done(); }, error: () => fail('courses') })); + } + this.subscriptions.add(this.organizations.refreshOrganizations().subscribe({ next: value => { this.counts.organizations = value.length; done(); }, error: () => fail('organizations') })); + this.subscriptions.add(this.badges.getBadges().subscribe({ next: value => { this.counts.badges = value.length; done(); }, error: () => fail('badges') })); + } +} diff --git a/frontend/src/app/pages/admin-reports/admin-reports.component.html b/frontend/src/app/pages/admin-reports/admin-reports.component.html new file mode 100644 index 0000000..b2f5b99 --- /dev/null +++ b/frontend/src/app/pages/admin-reports/admin-reports.component.html @@ -0,0 +1,7 @@ +

Admin / Reports

Platform reporting

A concise operational summary using the current Admin overview endpoint. These counts are not an analytics or audit system.

+ + +

Current counts

{{ report.totals.students }}

Users with student role

{{ report.totals.teachers }}

Users with teacher role

{{ report.totals.courses }}

Course records

{{ report.totals.total_enrollments }}

Enrollment records

{{ report.progress.courses_completed }}

Completed course enrollments

{{ report.progress.course_completion_rate }}%

Completed enrollments / total enrollments

+

Definitions and limitations

Active students
Distinct students with at least one enrollment record: {{ report.totals.active_students }}
Incomplete enrollments
Total enrollments minus completed enrollments: {{ report.totals.pending_enrollments }}
Lessons completed
Completed segment-progress records: {{ report.progress.lessons_completed }}
Units completed
Completed student-unit-progress records: {{ report.progress.units_completed }}

Counts are current database totals returned by the endpoint. They do not define time windows, trends, active account status, organization ranking, engagement scores, or audit history.

+
+
diff --git a/frontend/src/app/pages/admin-reports/admin-reports.component.scss b/frontend/src/app/pages/admin-reports/admin-reports.component.scss new file mode 100644 index 0000000..b8f5cfa --- /dev/null +++ b/frontend/src/app/pages/admin-reports/admin-reports.component.scss @@ -0,0 +1 @@ +@use '../../../styles/admin-production'; diff --git a/frontend/src/app/pages/admin-reports/admin-reports.component.ts b/frontend/src/app/pages/admin-reports/admin-reports.component.ts new file mode 100644 index 0000000..1031e57 --- /dev/null +++ b/frontend/src/app/pages/admin-reports/admin-reports.component.ts @@ -0,0 +1,25 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { AdminOverviewResponse, AnalyticsService } from '../../services/analytics.service'; + +@Component({ + selector: 'app-admin-reports', + standalone: true, + imports: [CommonModule, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './admin-reports.component.html', + styleUrl: './admin-reports.component.scss', +}) +export class AdminReportsComponent implements OnInit, OnDestroy { + report?: AdminOverviewResponse; + loading = true; + failed = false; + private readonly subscriptions = new Subscription(); + constructor(private readonly analytics: AnalyticsService) {} + ngOnInit(): void { this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + load(): void { this.loading = true; this.failed = false; this.subscriptions.add(this.analytics.getAdminOverview().subscribe({ next: report => { this.report = report; this.loading = false; }, error: () => { this.failed = true; this.loading = false; } })); } +} diff --git a/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.html b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.html new file mode 100644 index 0000000..b76a539 --- /dev/null +++ b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.html @@ -0,0 +1,23 @@ +
+ Back to users + + + + +

Admin / Users / Detail

{{ displayName }}

Account identity and platform access. Learning progress and private profile data are not shown here.

{{ roleLabel(user.role) }}
+ + +

Identity

Display name
{{ displayName }}
Username
{{ user.username }}
Email
{{ user.email }}
Created
{{ user.created_at | date:'medium' }}
Platform role
{{ roleLabel(user.role) | titlecase }}
Organization memberships
Not available from the current user API
+ +

Role and access

+

Your own role cannot be changed here.

This prevents accidental loss of access. The backend does not provide a protected self-role workflow.

+
+

Organization administrator and content administrator access is organization-scoped and is not assigned through this control. Partially implemented roles are not assignable here.

+
+ +

High-impact actions

Deleting this account is permanent and may remove authored discussions, badge awards, and learning links. It does not delete organizations or courses.

You cannot delete your own account from Admin.

+
+ + + +
diff --git a/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.scss b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.scss new file mode 100644 index 0000000..b8f5cfa --- /dev/null +++ b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.scss @@ -0,0 +1 @@ +@use '../../../styles/admin-production'; diff --git a/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.spec.ts b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.spec.ts new file mode 100644 index 0000000..34cda3d --- /dev/null +++ b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.spec.ts @@ -0,0 +1,40 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { convertToParamMap, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; + +import { AdminUserDetailComponent } from './admin-user-detail.component'; +import { UsersService } from '../../services/users.service'; +import { PermissionsService } from '../../services/permissions.service'; +import { ActivatedRoute } from '@angular/router'; + +describe('AdminUserDetailComponent', () => { + let fixture: ComponentFixture; + const user = { id: 'user-1', firstname: 'Ada', lastname: 'Lovelace', username: 'ada', email: 'ada@example.test', role: 'student', created_at: new Date() }; + const users = jasmine.createSpyObj('UsersService', ['getUser', 'updateUserRole', 'deleteUser']); + + beforeEach(async () => { + users.getUser.calls.reset(); + users.updateUserRole.calls.reset(); + users.deleteUser.calls.reset(); + users.getUser.and.returnValue(of(user)); users.updateUserRole.and.returnValue(of({ message: 'ok' })); users.deleteUser.and.returnValue(of({})); + await TestBed.configureTestingModule({ imports: [RouterTestingModule, AdminUserDetailComponent], providers: [ + { provide: UsersService, useValue: users }, { provide: PermissionsService, useValue: { user$: of({ user_id: 'admin-1' }) } }, + { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ userId: 'user-1' }) } } }, + ] }).compileComponents(); + fixture = TestBed.createComponent(AdminUserDetailComponent); fixture.detectChanges(); + }); + + it('requires confirmation before applying a supported role change', () => { + fixture.componentInstance.selectedRole = 'teacher'; fixture.componentInstance.requestRoleChange(); + expect(fixture.componentInstance.confirmRole).toBeTrue(); expect(users.updateUserRole).not.toHaveBeenCalled(); + fixture.componentInstance.applyRoleChange(); + expect(users.updateUserRole).toHaveBeenCalledWith(user, 'teacher'); expect(fixture.componentInstance.user?.role).toBe('teacher'); + }); + + it('keeps the displayed role unchanged after an API failure', () => { + users.updateUserRole.and.returnValue(throwError(() => new Error('failed'))); + fixture.componentInstance.selectedRole = 'admin'; fixture.componentInstance.requestRoleChange(); fixture.componentInstance.applyRoleChange(); + expect(fixture.componentInstance.user?.role).toBe('student'); expect(fixture.componentInstance.actionError).toContain('not changed'); + }); +}); diff --git a/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.ts b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.ts new file mode 100644 index 0000000..7f174a5 --- /dev/null +++ b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.ts @@ -0,0 +1,86 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { EchoConfirmationDialogComponent } from '../../components/echo-confirmation-dialog/echo-confirmation-dialog.component'; +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { User } from '../../models/user'; +import { PermissionsService } from '../../services/permissions.service'; +import { UsersService } from '../../services/users.service'; + +@Component({ + selector: 'app-admin-user-detail', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink, EchoConfirmationDialogComponent, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './admin-user-detail.component.html', + styleUrl: './admin-user-detail.component.scss', +}) +export class AdminUserDetailComponent implements OnInit, OnDestroy { + readonly assignableRoles = ['student', 'teacher', 'admin']; + user?: User; + selectedRole = ''; + currentUserId = ''; + loading = true; + failed = false; + saving = false; + deleting = false; + confirmRole = false; + confirmDelete = false; + actionError = ''; + successMessage = ''; + private readonly subscriptions = new Subscription(); + + constructor( + private readonly route: ActivatedRoute, + readonly router: Router, + private readonly users: UsersService, + permissions: PermissionsService, + ) { this.subscriptions.add(permissions.user$.subscribe(user => this.currentUserId = user?.user_id ?? '')); } + + ngOnInit(): void { this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + get isSelf(): boolean { return this.user?.id === this.currentUserId; } + get displayName(): string { return this.user ? `${this.user.firstname} ${this.user.lastname}`.trim() || 'Unnamed user' : ''; } + + load(): void { + const id = this.route.snapshot.paramMap.get('userId'); + if (!id) { this.loading = false; this.failed = true; return; } + this.loading = true; this.failed = false; + this.subscriptions.add(this.users.getUser(id).subscribe({ + next: user => { this.user = user; this.selectedRole = user.role; this.loading = false; }, + error: () => { this.failed = true; this.loading = false; }, + })); + } + + requestRoleChange(): void { + if (!this.user || this.isSelf || this.selectedRole === this.user.role || !this.assignableRoles.includes(this.selectedRole)) return; + this.actionError = ''; this.confirmRole = true; + } + + applyRoleChange(): void { + if (!this.user || this.saving) return; + this.saving = true; this.actionError = ''; + this.subscriptions.add(this.users.updateUserRole(this.user, this.selectedRole).subscribe({ + next: () => { + if (this.user) this.user = { ...this.user, role: this.selectedRole }; + this.saving = false; this.confirmRole = false; + this.successMessage = `Role updated to ${this.roleLabel(this.selectedRole)}.`; + }, + error: () => { this.saving = false; this.actionError = 'The role was not changed. Review the account and try again.'; }, + })); + } + + deleteUser(): void { + if (!this.user || this.isSelf || this.deleting) return; + this.deleting = true; this.actionError = ''; + this.subscriptions.add(this.users.deleteUser(this.user.id).subscribe({ + next: () => this.router.navigate(['/admin/users']), + error: () => { this.deleting = false; this.actionError = 'The user was not deleted. No local changes were made.'; }, + })); + } + + roleLabel(role: string): string { return role.replace(/_/g, ' '); } +} diff --git a/frontend/src/app/pages/admin-users/admin-users.component.html b/frontend/src/app/pages/admin-users/admin-users.component.html index cb91897..b1cb90f 100644 --- a/frontend/src/app/pages/admin-users/admin-users.component.html +++ b/frontend/src/app/pages/admin-users/admin-users.component.html @@ -1,29 +1,40 @@ -
-
-
-

Admin

-

All Signups

-
- User governance -
- - - - - - - - - - - - - - - - - -
NameRoleSignup DateActions
{{ user.firstname }} {{user.lastname}}{{ user.role }}{{ user.created_at | date: 'MMM d, y' }} - -
-
+
+
+

Admin / Users

Users

Review platform roles and open an account record for supported access changes.

+
+ + + + + +
+
+
+
+
+

{{ filteredUsers.length }} {{ filteredUsers.length === 1 ? 'user' : 'users' }} shown

+
+ + + + +
+

User directory

+
+ + +
Platform users and account actions
NameRoleCreatedActions
{{ displayName(user) }}{{ roleLabel(user.role) }}{{ user.created_at | date:'mediumDate' }}Review details
+
+
+
{{ displayName(user) }}
{{ roleLabel(user.role) }}
Created {{ user.created_at | date:'mediumDate' }}Review details
+
+
+
+ + +
diff --git a/frontend/src/app/pages/admin-users/admin-users.component.scss b/frontend/src/app/pages/admin-users/admin-users.component.scss index f696aed..b8f5cfa 100644 --- a/frontend/src/app/pages/admin-users/admin-users.component.scss +++ b/frontend/src/app/pages/admin-users/admin-users.component.scss @@ -1 +1 @@ -/* empty styles */ +@use '../../../styles/admin-production'; diff --git a/frontend/src/app/pages/admin-users/admin-users.component.spec.ts b/frontend/src/app/pages/admin-users/admin-users.component.spec.ts new file mode 100644 index 0000000..10bb291 --- /dev/null +++ b/frontend/src/app/pages/admin-users/admin-users.component.spec.ts @@ -0,0 +1,40 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; + +import { AdminUsersComponent } from './admin-users.component'; +import { UsersService } from '../../services/users.service'; +import { PermissionsService } from '../../services/permissions.service'; + +describe('AdminUsersComponent', () => { + let fixture: ComponentFixture; + const usersService = jasmine.createSpyObj('UsersService', ['getUsers', 'deleteUser']); + + beforeEach(async () => { + usersService.getUsers.and.returnValue(of([ + { id: '1', firstname: 'Ada', lastname: 'Lovelace', username: 'ada', email: 'ada@example.test', hashed_password: 'secret', role: 'student', created_at: new Date() }, + { id: '2', firstname: 'Grace', lastname: 'Hopper', username: 'grace', email: 'grace@example.test', role: 'admin', created_at: new Date() }, + ])); + await TestBed.configureTestingModule({ imports: [RouterTestingModule, AdminUsersComponent], providers: [ + { provide: UsersService, useValue: usersService }, { provide: PermissionsService, useValue: { user$: of({ user_id: '2' }) } }, + ] }).compileComponents(); + fixture = TestBed.createComponent(AdminUsersComponent); + }); + + it('omits contact and authentication fields from the broad user list', () => { + fixture.detectChanges(); const text = fixture.nativeElement.textContent; + expect(text).toContain('Ada Lovelace'); expect(text).not.toContain('ada@example.test'); expect(text).not.toContain('secret'); + }); + + it('filters by name and role with an announced result count', () => { + fixture.detectChanges(); fixture.componentInstance.search = 'Ada'; fixture.componentInstance.roleFilter = 'student'; fixture.detectChanges(); + expect(fixture.componentInstance.filteredUsers.length).toBe(1); + expect(fixture.nativeElement.textContent).toContain('1 user shown'); + }); + + it('renders a retryable load failure', () => { + usersService.getUsers.and.returnValue(throwError(() => new Error('failed'))); + fixture.componentInstance.loadUsers(); fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Users could not be loaded'); + }); +}); diff --git a/frontend/src/app/pages/admin-users/admin-users.component.ts b/frontend/src/app/pages/admin-users/admin-users.component.ts index 33d7bb0..0bc6d8a 100644 --- a/frontend/src/app/pages/admin-users/admin-users.component.ts +++ b/frontend/src/app/pages/admin-users/admin-users.component.ts @@ -1,29 +1,86 @@ -import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { UsersService } from '../../services/users.service'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { EchoConfirmationDialogComponent } from '../../components/echo-confirmation-dialog/echo-confirmation-dialog.component'; +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; import { User } from '../../models/user'; +import { PermissionsService } from '../../services/permissions.service'; +import { UsersService } from '../../services/users.service'; @Component({ selector: 'admin-users-page', standalone: true, - imports: [CommonModule], + imports: [CommonModule, FormsModule, RouterLink, EchoConfirmationDialogComponent, EchoLoadingStateComponent, EchoStatePanelComponent], templateUrl: './admin-users.component.html', styleUrl: './admin-users.component.scss' }) -export class AdminUsersComponent implements OnInit { +export class AdminUsersComponent implements OnInit, OnDestroy { users: User[] = []; + search = ''; + roleFilter = 'all'; + loading = true; + loadFailed = false; + currentUserId = ''; + pendingDelete?: User; + deleting = false; + deleteError = ''; + private readonly subscriptions = new Subscription(); - constructor(private usersService: UsersService) {} + constructor(private readonly usersService: UsersService, permissions: PermissionsService) { + this.subscriptions.add(permissions.user$.subscribe(user => this.currentUserId = user?.user_id ?? '')); + } + + ngOnInit(): void { this.loadUsers(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } - ngOnInit(): void { - this.usersService.getUsers().subscribe(users => { - this.users = users; + get roles(): string[] { return [...new Set(this.users.map(user => user.role))].sort(); } + get filteredUsers(): User[] { + const query = this.search.trim().toLowerCase(); + return this.users.filter(user => { + const name = `${user.firstname} ${user.lastname}`.toLowerCase(); + return (!query || name.includes(query)) && (this.roleFilter === 'all' || user.role === this.roleFilter); }); } - deleteUser(userId: string) { - this.usersService.deleteUser(userId).subscribe(() => { - this.users = this.users.filter(u => u.id !== userId); - }); + loadUsers(): void { + this.loading = true; + this.loadFailed = false; + this.subscriptions.add(this.usersService.getUsers().subscribe({ + next: users => { this.users = users; this.loading = false; }, + error: () => { this.loadFailed = true; this.loading = false; }, + })); } + + requestDelete(user: User): void { + if (user.id === this.currentUserId) return; + this.deleteError = ''; + this.pendingDelete = user; + } + + cancelDelete(): void { if (!this.deleting) this.pendingDelete = undefined; } + + confirmDelete(): void { + if (!this.pendingDelete || this.deleting) return; + const user = this.pendingDelete; + this.deleting = true; + this.deleteError = ''; + this.subscriptions.add(this.usersService.deleteUser(user.id).subscribe({ + next: () => { + this.users = this.users.filter(item => item.id !== user.id); + this.deleting = false; + this.pendingDelete = undefined; + }, + error: () => { + this.deleting = false; + this.deleteError = 'The user was not deleted. No local changes were made.'; + }, + })); + } + + displayName(user: User): string { return `${user.firstname} ${user.lastname}`.trim() || 'Unnamed user'; } + roleLabel(role: string): string { return role.replace(/_/g, ' '); } } diff --git a/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.html b/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.html index 2099b4c..5abb848 100644 --- a/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.html +++ b/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.ts b/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.ts index 72459e2..2da4681 100644 --- a/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.ts +++ b/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { UserInfo } from '../../../models/user-info'; -import { AdminViewComponent } from '../admin-view/admin-view.component'; +import { AdminOverviewComponent } from '../../admin-overview/admin-overview.component'; import { StudentViewComponent } from '../student-view/student-view.component'; import { TeacherViewComponent } from '../teacher-view/teacher-view.component'; import { SharedUserService } from '../../../services/shared-user.service'; @@ -9,7 +9,7 @@ import { SharedUserService } from '../../../services/shared-user.service'; @Component({ selector: 'echoed-role-selector', standalone: true, - imports: [CommonModule, AdminViewComponent, StudentViewComponent, TeacherViewComponent], + imports: [CommonModule, AdminOverviewComponent, StudentViewComponent, TeacherViewComponent], templateUrl: './echoed-role-selector.component.html', styleUrl: './echoed-role-selector.component.scss' }) diff --git a/frontend/src/app/services/permissions.service.ts b/frontend/src/app/services/permissions.service.ts index 10d61d9..b71248f 100644 --- a/frontend/src/app/services/permissions.service.ts +++ b/frontend/src/app/services/permissions.service.ts @@ -268,6 +268,7 @@ export class PermissionsService { permissions.add('nav:admin-badges'); permissions.add('nav:admin-users'); permissions.add('nav:admin-courses'); + permissions.add('nav:admin-reports'); } if (this.hasRole(roleSet, ['org_admin', 'content_admin'])) { diff --git a/frontend/src/app/services/shell-navigation.service.spec.ts b/frontend/src/app/services/shell-navigation.service.spec.ts index b6fbaa8..7af4fa5 100644 --- a/frontend/src/app/services/shell-navigation.service.spec.ts +++ b/frontend/src/app/services/shell-navigation.service.spec.ts @@ -92,16 +92,22 @@ describe('ShellNavigationService', () => { it('maps admins and super-admin-compatible users to Admin without inventing unsupported portals', () => { const adminLabels = service - .visibleSections(new Set(['role:admin', 'nav:admin-users', 'nav:commercial'])) + .visibleSections(new Set(['role:admin', 'nav:admin-users', 'nav:admin-courses', 'nav:admin-reports'])) .flatMap((section) => section.items.map((item) => item.label)); const superAdminLabels = service .visibleSections(new Set(['role:super_admin'])) .flatMap((section) => section.items.map((item) => item.label)); expect(service.getPrimarySpace(new Set(['role:admin'])).name).toBe('Admin'); + expect(service.getPrimarySpace(new Set(['role:admin'])).canonicalRoute).toBe('/admin'); expect(service.getPrimarySpace(new Set(['role:super_admin'])).name).toBe('Admin'); - expect(adminLabels).toContain('Community'); - expect(superAdminLabels).toEqual(['Admin Overview']); + expect(adminLabels).toContain('Users'); + expect(adminLabels).toContain('Organizations'); + expect(adminLabels).toContain('Courses'); + expect(adminLabels).toContain('Badges'); + expect(adminLabels).toContain('Reports'); + expect(adminLabels).not.toContain('Community'); + expect(superAdminLabels).toEqual(['Admin Overview', 'Organizations', 'Badges']); }); it('does not expose navigation for unsupported parent or viewer roles', () => { diff --git a/frontend/src/app/services/shell-navigation.service.ts b/frontend/src/app/services/shell-navigation.service.ts index e2ccff3..623c46f 100644 --- a/frontend/src/app/services/shell-navigation.service.ts +++ b/frontend/src/app/services/shell-navigation.service.ts @@ -91,13 +91,12 @@ export class ShellNavigationService { title: 'Admin', space: 'admin', items: [ - { label: 'Admin Overview', route: '/home', icon: 'Home', roles: ['admin', 'super_admin'], exact: true }, - { label: 'Users', route: '/home/admin/users', icon: 'Users', permission: 'nav:admin-users', roles: ['admin', 'super_admin'] }, - { label: 'Courses', route: '/home/admin/courses', icon: 'BookOpen', permission: 'nav:admin-courses', roles: ['admin', 'super_admin'] }, - { label: 'Badges', route: '/home/admin/badges', icon: 'Award', permission: 'nav:admin-badges', roles: ['admin', 'super_admin'] }, - { label: 'Reports', route: '/workspace/analytics', icon: 'SlidersHorizontal', permission: 'nav:analytics', roles: ['admin', 'super_admin'] }, - { label: 'Community', route: '/workspace/commercial', icon: 'Users', permission: 'nav:commercial', roles: ['admin', 'super_admin'] }, - { label: 'Settings', route: '/workspace/settings', icon: 'Settings', permission: 'nav:settings', roles: ['admin', 'super_admin'] }, + { label: 'Admin Overview', route: '/admin', icon: 'Home', roles: ['admin', 'super_admin'], exact: true }, + { label: 'Users', route: '/admin/users', icon: 'Users', permission: 'nav:admin-users', roles: ['admin'] }, + { label: 'Organizations', route: '/admin/organizations', icon: 'Users', roles: ['admin', 'super_admin'] }, + { label: 'Courses', route: '/admin/courses', icon: 'BookOpen', permission: 'nav:admin-courses', roles: ['admin'] }, + { label: 'Badges', route: '/admin/badges', icon: 'Award', roles: ['admin', 'super_admin'] }, + { label: 'Reports', route: '/admin/reports', icon: 'SlidersHorizontal', permission: 'nav:admin-reports', roles: ['admin'] }, ], }, ]; @@ -119,7 +118,7 @@ export class ShellNavigationService { name: 'Admin', eyebrow: 'EchoEd Admin', description: 'Manage people, curriculum oversight, reporting, and platform safeguards.', - canonicalRoute: '/home', + canonicalRoute: '/admin', }; } diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index a9c006b..1c4d6f1 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -18,6 +18,21 @@ export class UsersService { return this.http.get(`${this.apiUrl}`); } + getUser(userId: string): Observable { + return this.http.get(`${this.apiUrl}/${userId}`); + } + + updateUserRole(user: User, role: string): Observable<{ message: string }> { + return this.http.put<{ message: string }>(`${this.apiUrl}/${user.id}`, { + firstname: user.firstname, + lastname: user.lastname, + username: user.username, + email: user.email, + password: '', + role, + }); + } + deleteUser(userId: string): Observable { return this.http.delete(`${this.apiUrl}/${userId}`); } diff --git a/frontend/src/styles/_admin-production.scss b/frontend/src/styles/_admin-production.scss new file mode 100644 index 0000000..9440c4b --- /dev/null +++ b/frontend/src/styles/_admin-production.scss @@ -0,0 +1,106 @@ +.admin-page { + color: var(--ee-color-text-default); + display: grid; + gap: var(--ee-space-6); + margin-inline: auto; + max-width: var(--ee-space-layout-content-max); + padding: var(--ee-space-layout-page); + width: 100%; +} + +.admin-page__header { + display: flex; + gap: var(--ee-space-4); + justify-content: space-between; + align-items: flex-start; +} + +.admin-page__eyebrow { + color: var(--ee-color-action-primary-background); + font-size: var(--ee-font-size-xs); + font-weight: 800; + margin: 0 0 var(--ee-space-1); + text-transform: uppercase; +} + +.admin-page h1, +.admin-page h2, +.admin-page h3, +.admin-page p { margin-top: 0; } +.admin-page h1 { font-size: var(--ee-font-size-3xl); margin-bottom: var(--ee-space-2); } +.admin-page h2 { font-size: var(--ee-font-size-xl); margin-bottom: var(--ee-space-4); } +.admin-page__intro { color: var(--ee-color-text-muted); margin-bottom: 0; max-width: 46rem; } + +.admin-panel { + background: var(--ee-color-surface-raised); + border: 1px solid var(--ee-color-border-subtle); + border-radius: var(--ee-radius-card-default); + padding: var(--ee-space-5); +} + +.admin-summary-grid, +.admin-shortcuts, +.admin-card-grid { + display: grid; + gap: var(--ee-space-4); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); +} + +.admin-metric { border-left: 4px solid var(--ee-color-river-600); } +.admin-metric__value { font-size: var(--ee-font-size-3xl); font-weight: 800; line-height: 1; margin-bottom: var(--ee-space-2); } +.admin-metric__label { color: var(--ee-color-text-muted); font-size: var(--ee-font-size-sm); margin-bottom: 0; } + +.admin-link, +.admin-button { + align-items: center; + border-radius: var(--ee-radius-control-default); + display: inline-flex; + font-weight: 750; + justify-content: center; + min-height: 2.75rem; + padding: .65rem 1rem; + text-decoration: none; +} +.admin-link { color: var(--ee-color-text-link); justify-content: flex-start; } +.admin-button { background: var(--ee-color-action-primary-background); border: 1px solid var(--ee-color-action-primary-border); color: var(--ee-color-action-primary-text); cursor: pointer; } +.admin-button--secondary { background: var(--ee-color-action-secondary-background); border-color: var(--ee-color-action-secondary-border); color: var(--ee-color-action-secondary-text); } +.admin-button--danger { background: var(--ee-color-action-danger-background); border-color: var(--ee-color-action-danger-border); color: var(--ee-color-action-danger-text); } +.admin-button:disabled { cursor: not-allowed; opacity: .6; } +.admin-link:focus-visible, .admin-button:focus-visible, .admin-control:focus-visible { + outline: var(--ee-focus-ring-width) solid var(--ee-focus-ring-color); + outline-offset: var(--ee-focus-ring-offset); + box-shadow: var(--ee-focus-ring-shadow); +} + +.admin-toolbar { display: flex; flex-wrap: wrap; gap: var(--ee-space-3); align-items: end; } +.admin-field { display: grid; gap: var(--ee-space-1); min-width: min(100%, 13rem); } +.admin-field label { font-size: var(--ee-font-size-sm); font-weight: 750; } +.admin-control { background: var(--ee-color-surface-raised); border: 1px solid var(--ee-color-border-default); border-radius: var(--ee-radius-control-default); color: inherit; min-height: 2.75rem; padding: .6rem .75rem; width: 100%; } +.admin-results { color: var(--ee-color-text-muted); font-size: var(--ee-font-size-sm); margin: 0; } + +.admin-table-wrap { overflow-x: auto; } +.admin-table { border-collapse: collapse; min-width: 42rem; width: 100%; } +.admin-table th { background: var(--ee-color-surface-subtle); color: var(--ee-color-text-muted); font-size: var(--ee-font-size-sm); text-align: left; } +.admin-table th, .admin-table td { border-bottom: 1px solid var(--ee-color-border-subtle); padding: var(--ee-space-3); vertical-align: middle; } +.admin-table th:last-child, .admin-table td:last-child { text-align: right; } +.admin-mobile-list { display: none; } +.admin-mobile-record { display: grid; gap: var(--ee-space-3); } +.admin-definition { display: grid; gap: var(--ee-space-3); grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; } +.admin-definition div { border-bottom: 1px solid var(--ee-color-border-subtle); padding-bottom: var(--ee-space-3); } +.admin-definition dt { color: var(--ee-color-text-muted); font-size: var(--ee-font-size-sm); } +.admin-definition dd { font-weight: 650; margin: var(--ee-space-1) 0 0; overflow-wrap: anywhere; } +.admin-pill { background: var(--ee-color-status-info-background); border: 1px solid var(--ee-color-status-info-border); border-radius: var(--ee-radius-control-pill); color: var(--ee-color-status-info-text); display: inline-block; font-size: var(--ee-font-size-xs); font-weight: 800; padding: .2rem .55rem; text-transform: capitalize; } +.admin-pill--warning { background: var(--ee-color-status-warning-background); border-color: var(--ee-color-status-warning-border); color: var(--ee-color-status-warning-text); } +.admin-callout { background: var(--ee-color-status-info-background); border: 1px solid var(--ee-color-status-info-border); border-radius: var(--ee-radius-card-default); color: var(--ee-color-status-info-text); padding: var(--ee-space-4); } +.admin-callout p:last-child { margin-bottom: 0; } +.admin-actions { display: flex; flex-wrap: wrap; gap: var(--ee-space-3); } +.admin-danger-zone { border-color: var(--ee-color-status-danger-border); } + +@media (max-width: 47.99rem) { + .admin-page__header { display: grid; } + .admin-table-wrap { display: none; } + .admin-mobile-list { display: grid; gap: var(--ee-space-3); } + .admin-definition { grid-template-columns: 1fr; } + .admin-toolbar, .admin-field { width: 100%; } + .admin-button { width: 100%; } +} diff --git a/frontend/tests/demo/admin-platform-smoke.spec.ts b/frontend/tests/demo/admin-platform-smoke.spec.ts new file mode 100644 index 0000000..2f62496 --- /dev/null +++ b/frontend/tests/demo/admin-platform-smoke.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; + +const ADMIN_USERNAME = process.env['DEMO_ADMIN_USERNAME'] || 'orgadmin'; +const ADMIN_PASSWORD = process.env['DEMO_ADMIN_PASSWORD'] || 'password'; + +async function signIn(page: import('@playwright/test').Page, username: string, password: string) { + await page.goto('/login'); + await page.getByLabel('Email or Username').fill(username); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Sign in' }).click(); +} + +test.describe('seeded platform administrator smoke', () => { + test('administrator can inspect supported Admin oversight without completing destructive actions', async ({ page }) => { + await signIn(page, ADMIN_USERNAME, ADMIN_PASSWORD); + await expect(page).toHaveURL(/\/admin$/); + await expect(page.getByRole('heading', { name: 'Admin overview' })).toBeVisible(); + + await page.getByRole('link', { name: 'Users', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Users', exact: true })).toBeVisible(); + await page.getByLabel('Search by name').fill('Eshe'); + await expect(page.getByText('1 user shown')).toBeVisible(); + await page.getByRole('link', { name: 'Review Eshe Steady' }).click(); + await expect(page.getByRole('heading', { name: 'Identity' })).toBeVisible(); + await expect(page.getByText('Organization memberships')).toBeVisible(); + const deleteButton = page.getByRole('button', { name: 'Delete user account' }); + if (await deleteButton.isEnabled()) { + await deleteButton.click(); + await expect(page.getByRole('dialog', { name: 'Delete user account' })).toBeVisible(); + await expect(page.getByText(/cannot be undone/i)).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + } + + await page.getByRole('link', { name: 'Organizations', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Organizations' })).toBeVisible(); + await expect(page.getByText('Scoped organization visibility')).toBeVisible(); + + await page.getByRole('link', { name: 'Courses', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Course oversight' })).toBeVisible(); + await expect(page.getByText('Create course')).toHaveCount(0); + + await page.getByRole('link', { name: 'Badges', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Badge administration' })).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole('heading', { name: 'Badge administration' })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); + }); + + test('student deep link to Admin is rejected by the shared permission flow', async ({ page }) => { + await signIn(page, process.env['DEMO_STUDENT_USERNAME'] || 'normalstudent', process.env['DEMO_STUDENT_PASSWORD'] || 'password'); + await expect(page).toHaveURL(/\/learn$/); + await page.goto('/admin/users'); + await expect(page).toHaveURL(/\/access-denied$/); + await expect(page.getByText(/permission|access/i).first()).toBeVisible(); + }); +}); diff --git a/frontend/tests/demo/student-flagship-smoke.spec.ts b/frontend/tests/demo/student-flagship-smoke.spec.ts index 7d72535..1d69b0e 100644 --- a/frontend/tests/demo/student-flagship-smoke.spec.ts +++ b/frontend/tests/demo/student-flagship-smoke.spec.ts @@ -17,7 +17,7 @@ test.describe('demo student flagship smoke', () => { await expect(page).toHaveURL(/\/learn\/lesson\//); await expect(page.getByLabel('Lesson experience')).toBeVisible(); await expect( - page.getByRole('button', { name: 'Exit lesson and return to Learn' }), + page.getByRole('button', { name: 'Return to the course overview' }), ).toBeVisible(); }); }); diff --git a/frontend/tests/demo/support/demo-smoke.ts b/frontend/tests/demo/support/demo-smoke.ts index a095d06..1943d4d 100644 --- a/frontend/tests/demo/support/demo-smoke.ts +++ b/frontend/tests/demo/support/demo-smoke.ts @@ -20,5 +20,5 @@ export async function loginAsDemoStudent(page: Page): Promise { } export async function expectFlagshipCourseOnStudentDashboard(page: Page): Promise { - await expect(page.getByText(FLAGSHIP_COURSE_TITLE)).toBeVisible(); + await expect(page.getByTestId('student-active-course-title')).toHaveText(FLAGSHIP_COURSE_TITLE); } diff --git a/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md b/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md index 0d7261f..bfae52c 100644 --- a/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md +++ b/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md @@ -46,9 +46,9 @@ ## 7. Administrator Experience -- [ ] 7.1 Redesign admin dashboard with alerts before routine metrics; affected screens: admin dashboard; APIs: `/api/analytics/overview`, users, courses, governance summary; tests: admin specs; a11y: alert ordering; responsive: metrics stack; frontend-only: yes; rollback: old admin view. -- [ ] 7.2 Redesign user and role management with confirmation flows; affected screens: `/home/admin/users`; APIs: `/api/users`, org/invite APIs; tests: user management specs; a11y: data list and confirmation; responsive: filters drawer; frontend-only: yes; rollback: old admin users. -- [ ] 7.3 Redesign course, badge, upload/asset, reporting, moderation, and platform settings surfaces; affected screens: admin courses, badges, analytics/reports, review/moderation; APIs: courses, badges, uploads, analytics, review, posts/threads; tests: page specs; a11y: destructive/publish confirmations; responsive: data list; frontend-only: yes unless asset library/moderation backend is selected; rollback: old surfaces. +- [x] 7.1 Redesign admin dashboard with alerts before routine metrics; affected screens: admin dashboard; APIs: `/api/analytics/overview`, users, courses, governance summary; tests: admin specs; a11y: alert ordering; responsive: metrics stack; frontend-only: yes; rollback: old admin view. +- [x] 7.2 Redesign user and role management with confirmation flows; affected screens: `/home/admin/users`; APIs: `/api/users`, org/invite APIs; tests: user management specs; a11y: data list and confirmation; responsive: filters drawer; frontend-only: yes; rollback: old admin users. +- [x] 7.3 Redesign course, badge, upload/asset, reporting, moderation, and platform settings surfaces; affected screens: admin courses, badges, analytics/reports, review/moderation; APIs: courses, badges, uploads, analytics, review, posts/threads; tests: page specs; a11y: destructive/publish confirmations; responsive: data list; frontend-only: yes unless asset library/moderation backend is selected; rollback: old surfaces. ## 8. Studio, Organization, and Community Surfaces