diff --git a/backend/.env b/backend/.env index c88bfa8..47eeba7 100644 --- a/backend/.env +++ b/backend/.env @@ -1 +1,9 @@ -PORT=8080 \ No newline at end of file +PORT=8080 + +# SMTP mailer configuration. Email delivery is enabled only when both +# EMAIL_USER and EMAIL_PASS are non-empty. +EMAIL_FROM=School Chain Manager +EMAIL_HOST=email-smtp.us-east-1.amazonaws.com +EMAIL_PORT=587 +EMAIL_USER= +EMAIL_PASS= diff --git a/backend/.env.example b/backend/.env.example index 9a23f81..ee7fcb3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -37,8 +37,8 @@ SEED_USER_PASSWORD=replace_with_local_seed_password # Optional external integrations. GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -EMAIL_FROM=School Chain Manager -EMAIL_HOST= +EMAIL_FROM=School Chain Manager +EMAIL_HOST=email-smtp.us-east-1.amazonaws.com EMAIL_PORT=587 EMAIL_USER= EMAIL_PASS= diff --git a/backend/docs/content-catalog.md b/backend/docs/content-catalog.md index e44e38c..cdb26ef 100644 --- a/backend/docs/content-catalog.md +++ b/backend/docs/content-catalog.md @@ -27,7 +27,7 @@ DTO fields: `id`, `content_type`, `payload`, `updatedAt`. ## Access Rules - `GET /api/content-catalog/read/:contentType` requires an authenticated user and returns only records where `active = true`. - All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited. -- `esa-funding-content` management requires `MANAGE_ESA_FUNDING_CONTENT` and campus effective scope. Parent users manage campus ESA content by drilling into a campus. Editing ESA content also stores the updated section name, bumps the linked `policy_documents` ESA acknowledgment document version, and makes staff re-acknowledge. +- `esa-funding-content` management requires `MANAGE_ESA_FUNDING_CONTENT`, campus effective scope, and `organizations.esaEnabled = true`. Parent users manage campus ESA content by drilling into a campus. Editing ESA content also stores the updated section name, bumps the linked `policy_documents` ESA acknowledgment document version, and makes staff re-acknowledge. - `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library. - `safety-qbs-quiz` is also organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` manage the weekly quiz and key reminders once for the organization; school, campus, and class users only read and complete that organization-owned quiz. - `emotional-intelligence-assessment-questions` and `emotional-intelligence-personality-quiz` are organization-scoped catalogs. Organization users with `MANAGE_CONTENT_CATALOG` manage the active quiz content once for the organization; descendant scopes read and complete that organization-owned content. diff --git a/backend/docs/data-model-guide.md b/backend/docs/data-model-guide.md index e460516..b7a8d85 100644 --- a/backend/docs/data-model-guide.md +++ b/backend/docs/data-model-guide.md @@ -30,6 +30,13 @@ Also: every tenant-owned table carries `organizationId` (+ optional `campusId`) | Backend-owned editable content | **`content_catalog`** | scoped/editable JSONB payloads by `content_type`; truly global static catalogs stay in frontend constants. | | File upload/download | the **file subsystem** + `file` table | see `file.md`; downloads enforce per-file ownership. | +## Organization-level feature flags + +`organizations.esaEnabled` controls whether the ESA Funding Info module is available for an +organization. It defaults to `true` for existing and newly seeded organizations. Turning it off hides +the frontend ESA module/routes for users in that organization and blocks backend reads/management of +the campus-scoped `esa-funding-content` payload. + ## Reserved SIS cluster — kept but **not yet wired** These generated tables have **no product UI yet**. They are retained for future diff --git a/backend/docs/database-schema.md b/backend/docs/database-schema.md index fd15f92..0c9dc2a 100644 --- a/backend/docs/database-schema.md +++ b/backend/docs/database-schema.md @@ -119,6 +119,8 @@ Tenant root. Every tenant-owned row references an organization via `organization |---|---|---|---|---| | `id` | uuid | no | UUIDV4 | PK | | `name` | text | yes | — | | +| `logo` | text | yes | — | tenant branding image URL/private URL | +| `esaEnabled` | boolean | no | true | enables ESA Funding Info and campus ESA content management | | `importHash` | varchar | yes | — | unique, audit | | `createdAt` | timestamptz | yes | — | audit | | `updatedAt` | timestamptz | yes | — | audit | diff --git a/backend/docs/email.md b/backend/docs/email.md index 9f817e6..c7b0a7d 100644 --- a/backend/docs/email.md +++ b/backend/docs/email.md @@ -40,7 +40,7 @@ Shared used: `@/shared/config` (the `email` config block), `@/shared/logger`, - `get transportConfig()` — returns `config.email`. - `get from()` — returns `config.email.from`. -Each message class takes its recipient plus a link/host in the constructor, exposes a +Each message class takes its recipient plus a link or message-specific options in the constructor, exposes a `subject` getter, and an `async html(): Promise` that loads its template and substitutes placeholders. They are passed to `new EmailSender(...).send()`: @@ -52,11 +52,11 @@ substitutes placeholders. They are passed to `new EmailSender(...).send()`: `getNotification('emails.passwordReset.subject', getNotification('app.title'))`; `html()` reads `passwordReset/passwordResetEmail.html` and replaces `{appTitle}` with `app.title`, `{resetUrl}` with `link`, `{accountName}` with `to`. -- `InvitationEmail(to: string, host: string)` — subject from +- `InvitationEmail(to: string, { loginUrl, temporaryPassword })` — subject from `getNotification('emails.invitation.subject', getNotification('app.title'))`; - `html()` reads `invitation/invitationTemplate.html`, builds - `signupUrl = `${host}&invitation=true`` and replaces `{appTitle}` with `app.title`, - `{signupUrl}` with that URL, `{to}` with `to`. + `html()` reads `invitation/invitationTemplate.html` and replaces `{appTitle}`, + `{loginUrl}`, `{email}`, and `{temporaryPassword}`. Values are HTML-escaped before + substitution. ## Behavior / Notes @@ -68,6 +68,10 @@ substitutes placeholders. They are passed to `new EmailSender(...).send()`: (`EMAIL_PASS`, no default), and `tls.rejectUnauthorized: false`. Because `isConfigured` requires both `auth.user` and `auth.pass`, email sending is effectively disabled until both are provided. +- `backend/.env` is the checked-in development override file. It carries the non-secret SMTP + defaults (`EMAIL_FROM`, `EMAIL_HOST`, `EMAIL_PORT`) plus empty `EMAIL_USER`/`EMAIL_PASS` slots. + Fill those two credentials only in the local/VM environment that should send mail. Do not commit + production SMTP credentials. - Template loading uses `import.meta.dirname` (aliased to `__dirname` in each file) to resolve the template path relative to the running module, then `fs.readFile(path, 'utf8')`. Substitution is plain global `String.replace` of the `{placeholder}` tokens. On read or @@ -84,20 +88,55 @@ substitutes placeholders. They are passed to `new EmailSender(...).send()`: - `signin` treats the user as `emailVerified` when `EmailSender.isConfigured` is false (so unconfigured environments do not block login). - `sendEmailAddressVerificationEmail(email, host?)` generates a verification token, - builds `${host}/verify-email?token=...`, and sends an `EmailAddressVerificationEmail`. - - `sendPasswordResetEmail(email, type = 'register' | 'invitation', host?)` generates a - password-reset token, builds `${host}/password-reset?token=...`, and sends either an - `InvitationEmail` (when `type === 'invitation'`) or a `PasswordResetEmail` otherwise. + builds a frontend `/verify-email?token=...` URL, and sends an + `EmailAddressVerificationEmail`. + - `sendPasswordResetEmail(email, type = 'register', host?)` generates a password-reset + token, builds a frontend `/password-reset?token=...` URL, and sends a + `PasswordResetEmail`. + - `sendInvitationEmail(email, temporaryPassword, host?)` builds a frontend `/login` URL and + sends an `InvitationEmail` containing the login URL, account email, and plaintext temporary + password. The plaintext value is generated by the users service and is not stored. - `src/api/controllers/auth.controller.ts` exposes `EmailSender.isConfigured` over HTTP (`res.status(200).send(EmailSender.isConfigured)`) and calls the two `AuthService` send methods from its request handlers. -- `src/services/users.ts` (`UsersService`): `create` and `bulkImport` call - `AuthService.sendPasswordResetEmail(email, 'invitation', host)` to send invitations to - newly created/imported users. +- `src/services/users.ts` (`UsersService`): `create` and `bulkImport` generate temporary + passwords, store only their bcrypt hashes, mark invited internal accounts verified, and call + `AuthService.sendInvitationEmail(...)` after commit. Bulk import sends invitations only for + rows that the repository reports as created. When `EmailSender.isConfigured` is false, single-user + create responses include the generated `temporaryPassword` once so the creator can copy it from + the UI and deliver it manually. ## Tests -None yet. +- `src/services/users.test.ts` covers temporary-password hashing, invitation delivery, and the + duplicate/ignored-row guard for bulk import. + +## Configuration + +Set these variables in `backend/.env` for local development or in the VM process environment for +deployment: + +```bash +EMAIL_FROM="School Chain Manager " +EMAIL_HOST=email-smtp.us-east-1.amazonaws.com +EMAIL_PORT=587 +EMAIL_USER= +EMAIL_PASS= +``` + +`EMAIL_USER` and `EMAIL_PASS` are the activation switch: if either is empty, +`EmailSender.isConfigured` returns `false`, signup verification emails are skipped, and local sign-in +treats users as verified so development is not blocked. When both are present, the platform sends +verification, password-reset, and invitation emails through nodemailer. + +While email is disabled, `POST /api/users` and `POST /api/users/owner-with-organization` return the +generated temporary password once in the API response. The frontend displays it in the create form +with copy instructions. This fallback is only for the internal manual handoff workflow; the password +is not persisted as plaintext on the backend. + +For VM deployments, keep SMTP credentials in the VM secret environment (for example +`~/executor/.env` or the process manager environment) and restart the backend process after changing +them. The checked-in `backend/.env` should stay limited to non-secret defaults and local placeholders. ## Related diff --git a/backend/docs/users.md b/backend/docs/users.md index 0fbc902..d5326d6 100644 --- a/backend/docs/users.md +++ b/backend/docs/users.md @@ -5,7 +5,8 @@ `users` is the identity slice: it manages user accounts, their assigned role (`app_role`), per-user custom permissions, organization membership, optional avatar files, and the email-action tokens used for verification and password reset. The slice is hand-written: creating -a user (or bulk-importing users) triggers an invitation email containing a password-reset link. +a user (or bulk-importing users) provisions a generated temporary password and triggers an +invitation email with the login URL and credentials. ## Slice Files (by layer) @@ -19,7 +20,7 @@ a user (or bulk-importing users) triggers an invitation email containing a passw `findProfileById`, `createFromAuth`, `updatePassword`, token generation/lookup, `markEmailVerified`). - Model: `src/db/models/users.ts`. -- Shared used: `services/auth.ts` (`AuthService.sendPasswordResetEmail`), +- Shared used: `services/auth.ts` (`AuthService.sendInvitationEmail`), `db/api/shared/repository.ts`, `db/api/file.ts` (`replaceRelationFiles`), `db/utils.ts`, `shared/config.ts` (`config.roles`, `config.providers`, bcrypt settings), `shared/constants/roles.ts` (`ROLE_NAMES`), `shared/constants/auth.ts` @@ -36,8 +37,10 @@ route passes `checkCrudPermissions('users')`, requiring the permission `${METHOD (see `permissions.md`); the middleware's self-access bypass also lets a user act on their own record when `req.params.id`/`req.body.id` equals their own id. -- `POST /api/users` -> `200` `true`. Request body: `{ data: }`. Creates the user then - sends an invitation email. +- `POST /api/users` -> `200` `{ id, organizationId, temporaryPassword? }`. Request body: + `{ data: }`. Creates the user then sends an invitation email when mailer is + configured. If mailer is not configured, the response includes the generated + `temporaryPassword` exactly once so the creator can copy it and deliver it manually. - `POST /api/users/bulk-import` -> `200` `true`. Multipart CSV upload; every row must carry an `email`. Missing file raises `ValidationError('importer.errors.invalidFileEmpty')`. - `PUT /api/users/:id` -> `200` `true`. The controller calls `Service.update(req.body.data, @@ -106,10 +109,11 @@ for list filtering) through `usersCustom_permissionsPermissions`; `hasMany messa `class_enrollments_student` for enrollment-backed student class display and scope checks; `hasMany file` as `avatar`; `belongsTo users` as `createdBy`/`updatedBy`. -On `create`/`bulkImport` the repository sets `emailVerified` to `true` on single create and to -`false` (unless supplied) on bulk import. A user created without an explicit `app_role` has no role and falls back to the `guest` role -until one is assigned (roles are assigned explicitly by the provisioning flow). The service -layer (`services/users.ts`) also enforces the relational role policy +On `create`/`bulkImport`, the service generates a random temporary password, hashes it before +storing it, and marks the account `emailVerified = true` so the invited internal user can sign in +immediately. A user created without an explicit `app_role` has no role and falls back to the +`guest` role until one is assigned (roles are assigned explicitly by the provisioning flow). The +service layer (`services/users.ts`) also enforces the relational role policy (`assertCanManageUserWithRole`), a same-organization guard (`assertSameTenant`) for non-global actors, and auto-creates the company when an `owner` is created (§3.3/§3.4). @@ -122,19 +126,19 @@ List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`, ## Behavior / Notes -- Invitation workflow: after a successful `create` commit, `AuthService.sendPasswordResetEmail` - is called with type `'invitation'` and the host resolved from the request `Referer`, sending the - new user a `/password-reset?token=...` link. The token is generated by - `UsersDBApi.generatePasswordResetToken` with `EMAIL_ACTION_TOKEN_BYTES`/`EMAIL_ACTION_TOKEN_TTL_MS`. +- Invitation workflow: before persistence, the service generates a temporary password with + `crypto.randomBytes(...).toString('base64url')`, stores only the bcrypt hash, and retains the + plaintext value only long enough to send `AuthService.sendInvitationEmail(...)` after commit. + The email contains the login URL, the user's email, and the temporary password. This is accepted + for the internal platform workflow; users are expected to change the password from their profile. + If `EmailSender.isConfigured` is false, the same plaintext temporary password is returned once + from `POST /api/users` / `POST /api/users/owner-with-organization` instead of relying on email. + It is still not stored outside the bcrypt hash. - Bulk import: parses the uploaded CSV (`csv-parser`); requires every row to carry an `email` (else `ValidationError('importer.errors.userEmailMissing')`); `bulkCreate`s with `ignoreDuplicates: true` and staggered `createdAt` (`BULK_IMPORT_TIMESTAMP_STEP_MS`); attaches - avatar files per row. -- Note (inconsistency in source): both `create` and `bulkImport` accept a `sendInvitationEmails` - flag (controller passes `true`). In `create`, emails are sent only when `sendInvitationEmails` - is truthy (`if (!sendInvitationEmails) return;`), but in `bulkImport` the email loop runs only - when `!sendInvitationEmails`. With the controller always passing `true`, single-create users are - emailed while bulk-imported users are not. + avatar files per row. Invitation emails are sent only for rows returned by the repository as + created, so duplicate/ignored CSV rows do not receive unsaved temporary passwords. - All mutations run inside a manual Sequelize transaction (commit on success, rollback on error). - `findBy` returns a per-request `AuthenticatedUser` (role + its permissions, custom permissions, organization) used by authentication/authorization; `findProfileById` @@ -142,8 +146,9 @@ List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`, ## Tests -- `src/services/users.test.ts` covers teacher create/update access for student and guardian - accounts, including enrollment-backed students that do not have a direct `users.classId`. +- `src/services/users.test.ts` covers temporary-password provisioning, invite delivery for created + users only, password hashing on admin updates, and teacher create/update access for student and + guardian accounts, including enrollment-backed students that do not have a direct `users.classId`. - `src/services/guardian_students.test.ts` covers class-scoped guardian-student linking for enrollment-backed students and rejects links outside the teacher's class. - `src/services/shared/role-policy.test.ts` covers the role-management matrix, including teacher diff --git a/backend/src/db/api/organizations.ts b/backend/src/db/api/organizations.ts index ffa22fb..8177efb 100644 --- a/backend/src/db/api/organizations.ts +++ b/backend/src/db/api/organizations.ts @@ -44,6 +44,7 @@ class OrganizationsDBApi { id: data.id || undefined, name: data.name || null, logo: data.logo || null, + esaEnabled: data.esaEnabled ?? true, importHash: data.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, @@ -62,6 +63,7 @@ class OrganizationsDBApi { const organizationsData = data.map((item, index) => ({ id: item.id || undefined, name: item.name || null, + esaEnabled: item.esaEnabled ?? true, importHash: item.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, @@ -89,6 +91,7 @@ class OrganizationsDBApi { if (data.name !== undefined) updatePayload.name = data.name; if (data.logo !== undefined) updatePayload.logo = data.logo; + if (data.esaEnabled !== undefined) updatePayload.esaEnabled = data.esaEnabled; updatePayload.updatedById = currentUser.id; diff --git a/backend/src/db/api/types.ts b/backend/src/db/api/types.ts index 39f51ff..0bb36fc 100644 --- a/backend/src/db/api/types.ts +++ b/backend/src/db/api/types.ts @@ -59,7 +59,7 @@ export type AuthenticatedUser = InferAttributes & { /** Minimal shape of the authenticated user passed through the data layer. */ export interface CurrentUser { id: string | null; - organizations?: { id: string | null } | null; + organizations?: { id: string | null; esaEnabled?: boolean | null } | null; organizationId?: string | null; app_role?: { globalAccess?: boolean | null; diff --git a/backend/src/db/initial-schema.ts b/backend/src/db/initial-schema.ts index c94f1b6..198954c 100644 --- a/backend/src/db/initial-schema.ts +++ b/backend/src/db/initial-schema.ts @@ -35,7 +35,7 @@ DO 'BEGIN CREATE TYPE "public"."enum_messages_channel" AS ENUM(''in_app'', ''ema DO 'BEGIN CREATE TYPE "public"."enum_messages_audience" AS ENUM(''all_org'', ''campus'', ''class'', ''staff'', ''students'', ''guardians'', ''custom''); EXCEPTION WHEN duplicate_object THEN null; END'; DO 'BEGIN CREATE TYPE "public"."enum_messages_status" AS ENUM(''draft'', ''scheduled'', ''sent'', ''failed''); EXCEPTION WHEN duplicate_object THEN null; END'; CREATE TABLE IF NOT EXISTS "messages" ("id" UUID , "subject" TEXT, "body" TEXT, "channel" "public"."enum_messages_channel", "audience" "public"."enum_messages_audience", "sent_at" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_messages_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "sent_byId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); -CREATE TABLE IF NOT EXISTS "organizations" ("id" UUID , "name" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "organizations" ("id" UUID , "name" TEXT, "logo" TEXT, "esaEnabled" BOOLEAN NOT NULL DEFAULT true, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "permissions" ("id" UUID , "name" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "personality_quiz_results" ("id" UUID , "quiz_kind" TEXT NOT NULL DEFAULT 'personality_type', "quiz_id" TEXT NOT NULL DEFAULT 'personality-type', "quiz_title" TEXT NOT NULL DEFAULT 'Personality Type Quiz', "week_of" TEXT, "personality_type" TEXT, "quiz_answers" JSONB NOT NULL, "score" INTEGER, "total_questions" INTEGER NOT NULL DEFAULT 0, "result_label" TEXT, "result_payload" JSONB, "user_name" TEXT, "user_role" TEXT, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "roles" ("id" UUID , "name" TEXT, "globalAccess" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); diff --git a/backend/src/db/migrations/20260626093000-add-organization-esa-enabled.ts b/backend/src/db/migrations/20260626093000-add-organization-esa-enabled.ts new file mode 100644 index 0000000..8bf7857 --- /dev/null +++ b/backend/src/db/migrations/20260626093000-add-organization-esa-enabled.ts @@ -0,0 +1,31 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await columnExists(queryInterface, 'organizations', 'esaEnabled'))) { + await queryInterface.addColumn('organizations', 'esaEnabled', { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + if (await columnExists(queryInterface, 'organizations', 'esaEnabled')) { + await queryInterface.removeColumn('organizations', 'esaEnabled'); + } + }, +}; diff --git a/backend/src/db/models/organizations.ts b/backend/src/db/models/organizations.ts index ba78980..3aba88a 100644 --- a/backend/src/db/models/organizations.ts +++ b/backend/src/db/models/organizations.ts @@ -37,6 +37,7 @@ export class Organizations extends Model< declare id: CreationOptional; declare name: string | null; declare logo: CreationOptional; + declare esaEnabled: CreationOptional; declare importHash: CreationOptional; declare createdAt: CreationOptional; declare updatedAt: CreationOptional; @@ -285,6 +286,12 @@ name: { logo: { type: DataTypes.TEXT }, + esaEnabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + importHash: { type: DataTypes.STRING(255), allowNull: true, diff --git a/backend/src/db/seeders/20260610020000-rbac-fixtures.ts b/backend/src/db/seeders/20260610020000-rbac-fixtures.ts index 455debf..8b40d1f 100644 --- a/backend/src/db/seeders/20260610020000-rbac-fixtures.ts +++ b/backend/src/db/seeders/20260610020000-rbac-fixtures.ts @@ -57,8 +57,8 @@ export default { // 1. The companies (idempotent): the primary tenant and the second tenant // used to prove cross-tenant isolation (Workstream 8). const orgSeeds: CreationAttributes[] = [ - { id: SEED_ORGANIZATION_ID, name: SEED_ORGANIZATION_NAME, createdAt, updatedAt }, - { id: SEED_ORGANIZATION_2_ID, name: SEED_ORGANIZATION_2_NAME, createdAt, updatedAt }, + { id: SEED_ORGANIZATION_ID, name: SEED_ORGANIZATION_NAME, esaEnabled: true, createdAt, updatedAt }, + { id: SEED_ORGANIZATION_2_ID, name: SEED_ORGANIZATION_2_NAME, esaEnabled: true, createdAt, updatedAt }, ]; const existingOrgs = await queryInterface.sequelize.query<{ id: string }>( `SELECT id FROM organizations WHERE id IN (:ids)`, diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index 0ce414f..bfb44e9 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -48,7 +48,7 @@ function asId(value: unknown): string { } function frontendUrl(host: string | undefined, path: string): string { - const fallbackBase = config.backUrl || config.uiUrl || 'http://localhost:3000'; + const fallbackBase = config.uiUrl || config.backUrl || 'http://localhost:3000'; const base = host ? (/^https?:\/\//i.test(host) ? host : `https://${host}`) : fallbackBase; @@ -87,6 +87,7 @@ function toOrganizationDto(organization: unknown): OrganizationDto | null { id, name: asStringOrNull(plain.name), logo: asStringOrNull(plain.logo), + esaEnabled: plain.esaEnabled !== false, }; } @@ -460,6 +461,10 @@ class Auth { return new EmailSender(invitationEmail).send(); } + static isEmailConfigured(): boolean { + return EmailSender.isConfigured; + } + static async verifyEmail(token: string, options: DbApiOptions = {}) { const user = await UsersDBApi.findByEmailVerificationToken(token, options); diff --git a/backend/src/services/auth.types.ts b/backend/src/services/auth.types.ts index a92ff8a..2ece206 100644 --- a/backend/src/services/auth.types.ts +++ b/backend/src/services/auth.types.ts @@ -17,6 +17,7 @@ export interface OrganizationDto { id: string; name: string | null; logo?: string | null; + esaEnabled: boolean; } export interface CampusDto { diff --git a/backend/src/services/content_catalog.test.ts b/backend/src/services/content_catalog.test.ts index cbe5045..c5a6766 100644 --- a/backend/src/services/content_catalog.test.ts +++ b/backend/src/services/content_catalog.test.ts @@ -45,6 +45,7 @@ function campusEsaManager() { permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT }], }, organizationId: 'org-1', + organizations: { id: 'org-1' }, campusId: 'campus-1', }); } @@ -442,4 +443,27 @@ describe('ContentCatalogService tenant scoping', () => { }]); assert.deepEqual(commits, ['commit']); }); + + test('rejects ESA funding reads and management when organization disables ESA', async () => { + const disabledEsaManager = createGlobalAccessUser({ + app_role: { + name: ROLE_NAMES.DIRECTOR, + scope: ROLE_SCOPES.CAMPUS, + globalAccess: false, + permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT }], + }, + organizationId: 'org-1', + organizations: { id: 'org-1', esaEnabled: false }, + campusId: 'campus-1', + }); + + await assert.rejects( + () => ContentCatalogService.findByType('esa-funding-content', disabledEsaManager), + { name: 'ForbiddenError' }, + ); + await assert.rejects( + () => ContentCatalogService.findManagedByType('esa-funding-content', disabledEsaManager), + { name: 'ForbiddenError' }, + ); + }); }); diff --git a/backend/src/services/content_catalog.ts b/backend/src/services/content_catalog.ts index 3a1a9a3..32af439 100644 --- a/backend/src/services/content_catalog.ts +++ b/backend/src/services/content_catalog.ts @@ -138,11 +138,19 @@ function assertCanManageContentCatalog(currentUser?: CurrentUser): void { throw new ForbiddenError(); } +function assertEsaEnabled(contentType: string, currentUser?: CurrentUser): void { + if (contentType === ESA_CONTENT_TYPE && currentUser?.organizations?.esaEnabled === false) { + throw new ForbiddenError(); + } +} + /** * Edit authorization per content type. Tenant scoping constrains the row; the * right to edit content catalog data is a single effective permission. */ function assertCanManageType(contentType: string, currentUser?: CurrentUser): void { + assertEsaEnabled(contentType, currentUser); + if ( !hasFeaturePermission( currentUser, @@ -284,6 +292,7 @@ class ContentCatalogService { static async findByType(contentType: unknown, currentUser?: CurrentUser) { const normalizedContentType = assertValidContentType(contentType); + assertEsaEnabled(normalizedContentType, currentUser); // Tenant-scoped content is never served on the unauthenticated public path. if (TENANT_SCOPED_CONTENT_TYPES.has(normalizedContentType) && !currentUser?.id) { diff --git a/backend/src/services/users.test.ts b/backend/src/services/users.test.ts index c5bd00c..4658674 100644 --- a/backend/src/services/users.test.ts +++ b/backend/src/services/users.test.ts @@ -19,9 +19,9 @@ afterEach(() => { }); describe('UsersService password provisioning', () => { - test('creates a user with a hashed generated password and emails the plaintext temporary password', async () => { + test('creates a user with a hashed generated password and returns it when email delivery is not configured', async () => { let passwordStoredInCreate: string | null = null; - const invitations: Array<{ email: string; temporaryPassword: string; host?: string }> = []; + let invitationAttempted = false; mock.method(db.sequelize, 'transaction', async () => ({ commit: async () => undefined, @@ -36,37 +36,78 @@ describe('UsersService password provisioning', () => { mock.method( AuthService, 'sendInvitationEmail', - async (email: string, temporaryPassword: string, host?: string) => { - invitations.push({ email, temporaryPassword, host }); + async () => { + invitationAttempted = true; }, ); + mock.method(AuthService, 'isEmailConfigured', () => false); - await UsersService.create( + const result = await UsersService.create( { email: 'invitee@example.com', firstName: 'Invitee' }, undefined, true, 'https://app.example.test', ); - const invitation = invitations[0]; - assert.ok(invitation); - assert.equal(invitation.email, 'invitee@example.com'); - assert.equal(invitation.host, 'https://app.example.test'); - assert.ok(invitation.temporaryPassword.length >= 20); + assert.equal(invitationAttempted, false); + assert.ok(result.temporaryPassword); + assert.ok(result.temporaryPassword.length >= 20); assert.ok(passwordStoredInCreate); - assert.notEqual(passwordStoredInCreate, invitation.temporaryPassword); + assert.notEqual(passwordStoredInCreate, result.temporaryPassword); assert.equal( - await bcrypt.compare(invitation.temporaryPassword, passwordStoredInCreate), + await bcrypt.compare(result.temporaryPassword, passwordStoredInCreate), true, ); }); + test('does not return the generated password when email delivery is configured', async () => { + const invitations: Array<{ email: string; temporaryPassword: string; host?: string }> = []; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(UsersDBApi, 'findBy', async () => null); + mock.method(UsersDBApi, 'create', async () => ({ id: 'created-user-id' })); + mock.method( + AuthService, + 'sendInvitationEmail', + async (email: string, temporaryPassword: string, host?: string) => { + invitations.push({ email, temporaryPassword, host }); + }, + ); + mock.method(AuthService, 'isEmailConfigured', () => true); + + const result = await UsersService.create( + { email: 'invitee@example.com', firstName: 'Invitee' }, + undefined, + true, + 'https://app.example.test', + ); + + assert.equal(result.temporaryPassword, undefined); + const invitationSent = invitations[0]; + assert.ok(invitationSent); + assert.deepEqual({ + email: invitationSent.email, + host: invitationSent.host, + }, { + email: 'invitee@example.com', + host: 'https://app.example.test', + }); + assert.ok(invitationSent.temporaryPassword); + }); + test('bulk import hashes generated passwords and sends invitations when enabled', async () => { const csv = Buffer.from( 'email,firstName\none@example.com,One\ntwo@example.com,Two\n', ); const invitations: Array<{ email: string; temporaryPassword: string }> = []; - let importedRows: Array<{ email?: string; password?: string | null }> = []; + let importedRows: Array<{ + email?: string; + emailVerified?: boolean | string; + password?: string | null; + }> = []; mock.method(db.sequelize, 'transaction', async () => ({ commit: async () => undefined, @@ -83,6 +124,7 @@ describe('UsersService password provisioning', () => { invitations.push({ email, temporaryPassword }); }, ); + mock.method(AuthService, 'isEmailConfigured', () => true); await UsersService.bulkImport(csv, undefined, true, 'https://app.example.test'); @@ -92,12 +134,69 @@ describe('UsersService password provisioning', () => { for (const row of importedRows) { const invitation = invitations.find((item) => item.email === row.email); assert.ok(invitation); + assert.equal(row.emailVerified, true); assert.ok(row.password); assert.notEqual(row.password, invitation.temporaryPassword); assert.equal(await bcrypt.compare(invitation.temporaryPassword, row.password), true); } }); + test('bulk import sends invitations only for rows inserted by the repository', async () => { + const csv = Buffer.from( + 'email,firstName\nexisting@example.com,Existing\nnew@example.com,New\n', + ); + const invitations: Array<{ email: string; temporaryPassword: string }> = []; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method( + UsersDBApi, + 'bulkImport', + async (rows: Array<{ email?: string; password?: string | null }>) => { + const createdRow = rows.find((row) => row.email === 'new@example.com'); + assert.ok(createdRow?.password); + return [{ id: 'created-new', email: createdRow.email }]; + }, + ); + mock.method( + AuthService, + 'sendInvitationEmail', + async (email: string, temporaryPassword: string) => { + invitations.push({ email, temporaryPassword }); + }, + ); + mock.method(AuthService, 'isEmailConfigured', () => true); + + await UsersService.bulkImport(csv, undefined, true, 'https://app.example.test'); + + assert.equal(invitations.length, 1); + assert.equal(invitations[0].email, 'new@example.com'); + assert.ok(invitations[0].temporaryPassword); + }); + + test('bulk import does not send invitations when email delivery is not configured', async () => { + const csv = Buffer.from('email,firstName\none@example.com,One\n'); + let invitationAttempted = false; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(UsersDBApi, 'bulkImport', async (rows: Array<{ email?: string }>) => ( + rows.map((row, index) => ({ id: `created-${index}`, email: row.email })) + )); + mock.method(AuthService, 'sendInvitationEmail', async () => { + invitationAttempted = true; + }); + mock.method(AuthService, 'isEmailConfigured', () => false); + + await UsersService.bulkImport(csv, undefined, true, 'https://app.example.test'); + + assert.equal(invitationAttempted, false); + }); + test('admin user update hashes a supplied plaintext password before saving', async () => { const admin = createTestUser({ id: '11111111-1111-4111-8111-111111111111', @@ -189,7 +288,9 @@ describe('UsersService teacher class roster management', () => { false, ); - assert.deepEqual(result, { id: 'created-student-id', organizationId: null }); + assert.equal(result.id, 'created-student-id'); + assert.equal(result.organizationId, null); + assert.equal(typeof result.temporaryPassword, 'string'); assert.equal(typeof createPayload, 'object'); assert.notEqual(createPayload, null); }); @@ -420,7 +521,9 @@ describe('UsersService teacher class roster management', () => { false, ); - assert.deepEqual(result, { id: 'created-guardian-id', organizationId: null }); + assert.equal(result.id, 'created-guardian-id'); + assert.equal(result.organizationId, null); + assert.equal(typeof result.temporaryPassword, 'string'); assert.equal(createCalled, true); }); diff --git a/backend/src/services/users.ts b/backend/src/services/users.ts index 6b6a1dc..e7dae24 100644 --- a/backend/src/services/users.ts +++ b/backend/src/services/users.ts @@ -44,6 +44,12 @@ type InvitationCredential = { temporaryPassword: string; }; +interface CreateUserResult { + id: string; + organizationId: string | null; + temporaryPassword?: string; +} + const TEMPORARY_PASSWORD_BYTES = 18; function generateTemporaryPassword(): string { @@ -60,6 +66,7 @@ async function setGeneratedTemporaryPassword( const email = String(data.email ?? ''); const temporaryPassword = generateTemporaryPassword(); data.password = await hashPassword(temporaryPassword); + data.emailVerified = true; return { email, temporaryPassword }; } @@ -349,7 +356,7 @@ class UsersService { throw error; } - if (invitationsToSend.length && sendInvitationEmails) { + if (invitationsToSend.length && sendInvitationEmails && AuthService.isEmailConfigured()) { await Promise.all( invitationsToSend.map((invitation) => AuthService.sendInvitationEmail( @@ -361,7 +368,16 @@ class UsersService { ); } - return { id: createdId, organizationId: createdOrganizationId }; + const result: CreateUserResult = { + id: createdId, + organizationId: createdOrganizationId, + }; + const createdInvitation = invitationsToSend[0]; + if (createdInvitation && !AuthService.isEmailConfigured()) { + result.temporaryPassword = createdInvitation.temporaryPassword; + } + + return result; } static async createOwnerWithOrganization( @@ -416,20 +432,31 @@ class UsersService { invitationsToSend.push(await setGeneratedTemporaryPassword(row)); } - await UsersDBApi.bulkImport(rows, { + const createdUsers = await UsersDBApi.bulkImport(rows, { transaction, ignoreDuplicates: true, validate: true, currentUser, }); + const credentialsByEmail = new Map( + invitationsToSend.map((invitation) => [invitation.email, invitation]), + ); + invitationsToSend.splice( + 0, + invitationsToSend.length, + ...createdUsers + .map((user) => credentialsByEmail.get(user.email)) + .filter((invitation): invitation is InvitationCredential => Boolean(invitation)), + ); + await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } - if (invitationsToSend.length && sendInvitationEmails) { + if (invitationsToSend.length && sendInvitationEmails && AuthService.isEmailConfigured()) { await Promise.all( invitationsToSend.map((invitation) => AuthService.sendInvitationEmail( diff --git a/docs/deployment-vm.md b/docs/deployment-vm.md index 3f0e1ac..c020395 100644 --- a/docs/deployment-vm.md +++ b/docs/deployment-vm.md @@ -49,12 +49,15 @@ secrets, not committed to the repository): | `SECRET_KEY` | JWT signature (required — otherwise backend won't start) | | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | connection to local Postgres (`DB_PASS` = project UUID) | | `GOOGLE_CLIENT_ID/SECRET`, `MS_CLIENT_ID/SECRET` | OAuth (optional) | -| `SMTP_*`, `EMAIL_*`, `MAIL_*` | email (optional) | +| `EMAIL_FROM`, `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USER`, `EMAIL_PASS` | transactional email / SMTP (optional; active only when user + password are set) | | `CF_TUNNEL_*` | Cloudflare tunnel (for `cloudflared`, not for the application) | **From committed `backend/.env`** (not production-level secrets): - `PORT` (optional, defaults to 8080). +- Non-secret mailer defaults (`EMAIL_FROM`, `EMAIL_HOST`, `EMAIL_PORT`) and empty + `EMAIL_USER`/`EMAIL_PASS` placeholders. Put real SMTP credentials in the VM/process environment, + not in committed files. - Seed passwords have safe development defaults in `backend/src/db/seeders/20200430130759-admin-user.ts` and can be overridden through `SEED_ADMIN_PASSWORD`, `SEED_ADMIN_EMAIL`, and `SEED_USER_PASSWORD`; `.env` is only needed for overrides. @@ -295,3 +298,7 @@ FRONT_PORT=3001, SENTRY_DSN Application secrets (`SECRET_KEY`, `DB_PASS`, OAuth/SMTP, git tokens, `CF_TUNNEL_*`) are placed by the platform directly into the **pm2 environment** of `backend-dev`/`fl-executor` processes, not in `backend/.env`. + +For backend mail delivery, provide at least `EMAIL_USER` and `EMAIL_PASS`; the checked-in +`backend/.env` already supplies default `EMAIL_FROM`, `EMAIL_HOST`, and `EMAIL_PORT`. Restart +`backend-dev` after changing SMTP credentials. diff --git a/frontend/docs/auth-integration.md b/frontend/docs/auth-integration.md index 6dfd591..f45e8c4 100644 --- a/frontend/docs/auth-integration.md +++ b/frontend/docs/auth-integration.md @@ -23,8 +23,9 @@ Do not add secrets to frontend env files. Vite exposes `VITE_*` values to the br 3. The backend sets an HttpOnly auth cookie and returns the current user profile. 4. `useAuthSession` restores the session with `GET /api/auth/me`. 5. The backend returns the current product profile, including `app_role` (`{ id, name, scope, globalAccess }`), direct tenant scope (`organizationId` / `schoolId` / `campusId` / `classId`), `avatar`, `phoneNumber`, and effective `permissions`. The UI role is derived from `app_role.name` (one of the first-class role names); there is no separate `productRole`. -6. `useAuthSession.signOut` calls `POST /api/auth/signout`; the backend clears the auth cookie. -7. `SignInModal` delegates modal mode, form draft state, validation, and submit workflow to `useAuthModalWorkflow`. +6. After a successful sign-in, the frontend remembers the password typed by the user in `sessionStorage` so the profile password-change form can prefill `Current password`. This is session-scoped browser state only; the backend never returns or stores plaintext passwords. +7. `useAuthSession.signOut` calls `POST /api/auth/signout`; the backend clears the auth cookie and the frontend clears the remembered session password. +8. `SignInModal` delegates modal mode, form draft state, validation, and submit workflow to `useAuthModalWorkflow`. ## Refresh Tokens diff --git a/frontend/docs/content-catalog-integration.md b/frontend/docs/content-catalog-integration.md index a9a26f1..a971694 100644 --- a/frontend/docs/content-catalog-integration.md +++ b/frontend/docs/content-catalog-integration.md @@ -129,7 +129,7 @@ These dashboard catalog rows are seeded per tenant at organization, school, and ## Editable ESA Funding Content -ESA funding school impact items, staff role guidance, FAQ items with per-question audience visibility, parent conversation Q&A references, and resource records are part of the campus-scoped `esa-funding-content` content catalog payload. Users with `MANAGE_ESA_FUNDING_CONTENT` can edit that payload only from a campus effective scope; organization/school/platform users manage it by drilling into a campus. +ESA funding school impact items, staff role guidance, FAQ items with per-question audience visibility, parent conversation Q&A references, and resource records are part of the campus-scoped `esa-funding-content` content catalog payload. Users with `MANAGE_ESA_FUNDING_CONTENT` can edit that payload only from a campus effective scope while `organizations.esaEnabled` is `true`; organization/school/platform users manage it by drilling into a campus. Static ESA intro copy, the state-variation notice/checklist, key points, and approved-use cards live in `frontend/src/shared/constants/esaFunding.ts` because they are stable training copy, not editable runtime records. Student and guardian views read the same campus content but hide staff-only FAQ entries, staff role guidance, parent conversation references, and the staff acknowledgment card. diff --git a/frontend/docs/esa-funding-integration.md b/frontend/docs/esa-funding-integration.md index e6144dd..f5f8bdb 100644 --- a/frontend/docs/esa-funding-integration.md +++ b/frontend/docs/esa-funding-integration.md @@ -63,6 +63,10 @@ Content payload is seeded in: - Loading and error states are explicit through `StatePanel`. - Resource records with valid `http` or `https` URLs render as external links. Invalid or placeholder URLs render as unavailable instead of using no-op click handlers. - `MANAGE_ESA_FUNDING_CONTENT` users can edit the campus-scoped dynamic payload from campus effective scope. Parent-scope users manage a campus by drilling into that campus. +- Organizations can disable ESA from the Organization profile section on `/profile`. When + `organizations.esaEnabled` is `false`, the ESA Funding Info module is hidden from navigation and + direct route access, and the backend rejects ESA content reads/management for users in that + organization. - ESA funding content editing is embedded inside each dynamic content section as a collapsible local editor next to the content it changes. - Student and guardian users do not see staff-role guidance, the parent-conversation quick reference, or the staff acknowledgment card. - Staff acknowledgments are persisted per current ESA policy-document version through `policy_acknowledgments`; editing ESA content sends the section name to the backend, bumps the linked document version, and drives the header notification text. diff --git a/frontend/docs/my-class-integration.md b/frontend/docs/my-class-integration.md index 1c0d35f..2ce7c24 100644 --- a/frontend/docs/my-class-integration.md +++ b/frontend/docs/my-class-integration.md @@ -2,8 +2,9 @@ ## Purpose -`/my-class` gives class-scoped staff a roster view for their assigned class. The page lists students, -linked guardians, and assigned class staff. Teacher users with the required user-management +`/my-class` gives class-scoped staff a roster view for their assigned class and lets higher-scope +users view a classroom roster after drilling into a class from the scope switcher. The page lists +students, linked guardians, and assigned class staff. Users with the required user-management permissions can add and edit student user accounts directly from the Students section, including linked guardian accounts for each student. @@ -17,8 +18,11 @@ linked guardian accounts for each student. ## Behavior -- The page requires the signed-in user to have a `classId`; otherwise it shows the existing - unassigned-class message. +- The page resolves the current class from the selected effective scope first. If the selected scope + is a classroom, that classroom is used; otherwise the signed-in user's own `classId` is used for + class-scoped staff. +- Higher-scope users must drill into a classroom before `/my-class` can show a roster. Class-scoped + users without an assigned class see the unassigned-class message. - The Students section lists class-scoped student users from `GET /api/users?classId=`. Backend listing includes both direct `users.classId` matches and students linked through `class_enrollments`, which is how the seeded class roster is represented. @@ -29,6 +33,9 @@ linked guardian accounts for each student. email, and phone number. - Creating a student calls `POST /api/users` with `app_role=` and the current `classId`; editing calls `PUT /api/users/:id` with the same fixed role and class scope. +- When the backend returns `temporaryPassword` because the mailer is not configured, the form stays + open and shows the generated password once with copy instructions. Teachers must copy the student + or guardian password before leaving the form and deliver it manually. - The same form includes a repeatable Guardians section with photo upload. When guardian fields are entered, the UI creates or updates each `guardian` user through the shared user API, then calls `POST /api/guardian_students` to link that guardian to the student. The link endpoint is diff --git a/frontend/docs/sign-language-integration.md b/frontend/docs/sign-language-integration.md index 35864dd..a1e87d1 100644 --- a/frontend/docs/sign-language-integration.md +++ b/frontend/docs/sign-language-integration.md @@ -23,6 +23,7 @@ View: - `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx` - `frontend/src/components/sign-language/SignLanguageFilters.tsx` - `frontend/src/components/sign-language/SignLanguageGrid.tsx` +- `frontend/src/components/sign-language/SignLanguagePagination.tsx` - `frontend/src/components/sign-language/SignLanguageCard.tsx` - `frontend/src/components/sign-language/SignLanguageVideoModal.tsx` @@ -68,7 +69,8 @@ Content payloads are seeded in: drilled into a child tenant, the page still shows sign content, but it does not load/write learned-sign progress or render "Progress Saved" / "Learned" affordances. -- Selectors handle category counts, search/category filtering, progress percentage, video duration, filter normalization, media URL normalization, and YouTube search URL construction. +- Selectors handle category counts, search/category filtering, 12-card pagination, progress percentage, video duration, filter normalization, media URL normalization, and YouTube search URL construction. +- The grid displays 12 cards per page after search/category filters are applied. Changing search or category resets to page 1. Pagination stays visible for any non-empty result set, with previous/next controls disabled when there is only one page. - Sign item media is normalized before rendering: - image URLs are trimmed; - YouTube watch, short, embed, shorts, and raw video IDs are normalized to embed URLs; diff --git a/frontend/docs/test-coverage.md b/frontend/docs/test-coverage.md index 0a7dfcc..ac5c4bf 100644 --- a/frontend/docs/test-coverage.md +++ b/frontend/docs/test-coverage.md @@ -74,6 +74,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/shared/api/staffAttendance.test.ts` - `frontend/src/shared/api/userProgress.test.ts` - `frontend/src/shared/api/zoneCheckins.test.ts` +- `frontend/src/shared/auth/sessionPassword.test.ts` - `frontend/src/shared/api/walkthrough.test.ts` - `frontend/src/shared/architecture/import-boundaries.test.ts` - `frontend/src/shared/business/apiListRows.test.ts` @@ -83,7 +84,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/hooks/usePermissions.test.tsx` - `frontend/src/components/sign-in-modal/SignInForm.test.tsx` -These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, guardian-student API contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors including search over titles/descriptions and favorites-only filtering, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, My Class student/guardian payload normalization and permission gating, campus attendance mapping, calculations, and printable staff report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge/completion API, QBS/EI/personality completion reminders, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, unified profile quiz result rows including Daily Zone Check-In, sign language selectors, top bar display selectors, user progress normalization including Classroom Support favorite list/upsert API contracts, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance. +These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, one-time temporary-password create responses, guardian-student API contracts, session-scoped current-password storage, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors including search over titles/descriptions and favorites-only filtering, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, My Class student/guardian payload normalization and permission gating, campus attendance mapping, calculations, and printable staff report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge/completion API, QBS/EI/personality completion reminders, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, unified profile quiz result rows including Daily Zone Check-In, sign language selectors, top bar display selectors, user progress normalization including Classroom Support favorite list/upsert API contracts, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance. The component and hook tests verify SignInForm rendering, loading states, user interactions, form submission, password visibility toggling, auth session initialization, sign-in/sign-out flows, AuthExpiredError handling, modal workflow state management, and permissions hook behavior including has(), hasAny(), hasAll() methods with globalAccess support and explicit personal-workflow exclusions. @@ -130,6 +131,11 @@ Test credentials are hardcoded (see `AGENTS.md` for the full list): - Users: `@flatlogic.com` / `flatlogicUser123!` - Secondary tenant users: `2@flatlogic.com` / `flatlogicUser123!` +Tests that need mutable users must create temporary users through the public +Users API and remove them in cleanup instead of mutating or deleting seeded +fixture accounts. Shared setup helpers live in +`frontend/tests/e2e/helpers/seeded-users.ts`. + The seeded suite verifies: - Minimum content catalog seed set through the public backend API - Classroom timer, classroom support, sign language, and zones routes with UI assertions based on live backend response diff --git a/frontend/src/business/app-shell/selectors.test.ts b/frontend/src/business/app-shell/selectors.test.ts index 0c39fc6..5c79f84 100644 --- a/frontend/src/business/app-shell/selectors.test.ts +++ b/frontend/src/business/app-shell/selectors.test.ts @@ -58,6 +58,7 @@ const scopedModules: readonly Module[] = [ { id: 'timer', name: 'Timer', icon: 'timer', permissions: ['READ_TIMER'], color: '', routePath: '/timer' }, { id: 'qbs', name: 'Behavior Management', icon: 'shield', permissions: ['READ_QBS'], color: '', routePath: '/qbs-safety' }, { id: 'zones', name: 'Zones', icon: 'layers', permissions: ['READ_ZONES'], color: '', routePath: '/zones-of-regulation' }, + { id: 'esa', name: 'ESA Funding Info', icon: 'wallet', permissions: ['READ_ESA'], color: '', routePath: '/esa-funding' }, { id: 'director', name: 'Director', icon: 'chart', permissions: ['READ_DIRECTOR_DASHBOARD'], color: '', routePath: '/director-dashboard' }, ]; @@ -115,6 +116,20 @@ describe('app-shell selectors', () => { expect(getScopedModules(scopedModules, attendanceUser, 'class', false).map((m) => m.id)).toEqual([]); }); + it('hides ESA funding when the organization disables ESA', () => { + const esaUser = user(['READ_ESA']); + expect(getScopedModules(scopedModules, esaUser, 'campus', false).map((m) => m.id)).toEqual(['esa']); + expect(canAccessScopedModuleRoute(scopedModules, '/esa-funding', esaUser, 'campus', false)).toBe(true); + + const disabledEsaUser = { + ...esaUser, + organizations: { id: 'org-1', name: 'Demo Academy', esaEnabled: false }, + }; + + expect(getScopedModules(scopedModules, disabledEsaUser, 'campus', false).map((m) => m.id)).toEqual([]); + expect(canAccessScopedModuleRoute(scopedModules, '/esa-funding', disabledEsaUser, 'campus', false)).toBe(false); + }); + it('never shows the Director Dashboard via drill-down', () => { expect( getScopedModules(scopedModules, user(['READ_DASHBOARD', 'READ_DIRECTOR_DASHBOARD']), 'campus', true).map((m) => m.id), diff --git a/frontend/src/business/app-shell/selectors.ts b/frontend/src/business/app-shell/selectors.ts index 6d9eb79..e9bcf38 100644 --- a/frontend/src/business/app-shell/selectors.ts +++ b/frontend/src/business/app-shell/selectors.ts @@ -9,7 +9,12 @@ export function getAccessibleModules( modules: readonly Module[], user: CurrentUser | null | undefined, ): readonly Module[] { - return modules.filter((module) => hasAnyPermission(user, module.permissions)); + return modules.filter((module) => { + if (module.id === 'esa' && user?.organizations?.esaEnabled === false) { + return false; + } + return hasAnyPermission(user, module.permissions); + }); } const ALL_STAFF_TIERS: readonly ScopeTier[] = [ diff --git a/frontend/src/business/auth/hooks.ts b/frontend/src/business/auth/hooks.ts index 80d4339..50b1736 100644 --- a/frontend/src/business/auth/hooks.ts +++ b/frontend/src/business/auth/hooks.ts @@ -14,6 +14,10 @@ import { toUserProfile } from '@/business/auth/mappers'; import { useCampusCatalog } from '@/business/campuses/hooks'; import { AuthActionResult, AuthSessionState } from '@/business/auth/types'; import { getErrorMessage, getOptionalErrorMessage } from '@/shared/errors/errorMessages'; +import { + forgetRememberedCurrentPassword, + rememberCurrentPassword, +} from '@/shared/auth/sessionPassword'; import type { AuthModalDraft, AuthModalMode, @@ -35,6 +39,7 @@ export function useAuthSession(): AuthSessionState { const clearSession = useCallback(() => { setUser(null); + forgetRememberedCurrentPassword(); }, []); const redirectToLogin = useCallback(() => { @@ -87,6 +92,7 @@ export function useAuthSession(): AuthSessionState { try { const currentUser = await signIn({ email, password }); + rememberCurrentPassword(password); setUser(currentUser); return { error: null }; } catch (error) { diff --git a/frontend/src/business/my-class/selectors.test.ts b/frontend/src/business/my-class/selectors.test.ts index 4b9fbc7..228fe01 100644 --- a/frontend/src/business/my-class/selectors.test.ts +++ b/frontend/src/business/my-class/selectors.test.ts @@ -3,7 +3,9 @@ import { buildMyClassGuardianSaveData, buildMyClassStudentSaveData, canManageMyClassStudents, + getMyClassUnavailableMessage, hasMyClassGuardianFormValues, + resolveMyClassClassId, } from '@/business/my-class/selectors'; describe('my-class selectors', () => { @@ -87,4 +89,36 @@ describe('my-class selectors', () => { studentRoleId: null, })).toBe(false); }); + + it('resolves the selected classroom before falling back to the user class', () => { + expect(resolveMyClassClassId({ classId: 'own-class' }, null)).toBe('own-class'); + expect(resolveMyClassClassId({ classId: 'own-class' }, { + id: 'drilled-class', + level: 'class', + name: 'Tigers Homeroom 1', + logo: null, + })).toBe('drilled-class'); + expect(resolveMyClassClassId({ classId: null }, { + id: 'campus-1', + level: 'campus', + name: 'Tigers Campus', + logo: null, + })).toBeNull(); + }); + + it('uses scope-aware unavailable copy', () => { + expect(getMyClassUnavailableMessage({ + user: { classId: null, app_role: { scope: 'class' } }, + effectiveTenant: null, + })).toBe('You are not assigned to a class yet.'); + expect(getMyClassUnavailableMessage({ + user: { classId: null, app_role: { scope: 'campus' } }, + effectiveTenant: { + id: 'campus-1', + level: 'campus', + name: 'Tigers Campus', + logo: null, + }, + })).toBe('Select a classroom from the scope switcher to view its roster.'); + }); }); diff --git a/frontend/src/business/my-class/selectors.ts b/frontend/src/business/my-class/selectors.ts index a7efa11..36a02e5 100644 --- a/frontend/src/business/my-class/selectors.ts +++ b/frontend/src/business/my-class/selectors.ts @@ -1,4 +1,6 @@ import type { SaveUserData } from '@/business/my-class/api'; +import type { CurrentUser } from '@/shared/types/auth'; +import type { ActiveTenant } from '@/shared/types/scope'; export interface MyClassStudentFormValues { readonly namePrefix: string; @@ -73,3 +75,29 @@ export function canManageMyClassStudents(input: { && Boolean(input.classId) && Boolean(input.studentRoleId); } + +export function resolveMyClassClassId( + user: Pick | null | undefined, + effectiveTenant: ActiveTenant | null | undefined, +): string | null { + if (effectiveTenant?.level === 'class') { + return effectiveTenant.id; + } + + return user?.classId ?? null; +} + +export function getMyClassUnavailableMessage(input: { + readonly user: Pick | null | undefined; + readonly effectiveTenant: ActiveTenant | null | undefined; +}): string { + if (input.user?.app_role?.scope === 'class' && !input.user.classId) { + return 'You are not assigned to a class yet.'; + } + + if (input.effectiveTenant && input.effectiveTenant.level !== 'class') { + return 'Select a classroom from the scope switcher to view its roster.'; + } + + return 'You are not assigned to a class yet.'; +} diff --git a/frontend/src/business/sign-language/hooks.ts b/frontend/src/business/sign-language/hooks.ts index ca6a46a..48010fd 100644 --- a/frontend/src/business/sign-language/hooks.ts +++ b/frontend/src/business/sign-language/hooks.ts @@ -8,6 +8,7 @@ import { filterSignLanguageItems, getSignLanguageProgressPercent, normalizeSignLanguageItems, + paginateSignLanguageItems, selectSignLanguageSignOfWeek, } from '@/business/sign-language/selectors'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; @@ -135,6 +136,7 @@ export function useSignLanguagePage(): SignLanguagePage { const progress = useLearnedSignsProgress({ enabled: canPersistProgress }); const [searchQuery, setSearchQuery] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); + const [currentPage, setCurrentPage] = useState(1); const [selectedSignId, setSelectedSignId] = useState(null); const [signDraft, setSignDraft] = useState(null); const [signDraftMode, setSignDraftMode] = useState<'create' | 'edit' | null>(null); @@ -190,6 +192,10 @@ export function useSignLanguagePage(): SignLanguagePage { () => filterSignLanguageItems(signs, filters), [filters, signs], ); + const pagination = useMemo( + () => paginateSignLanguageItems(filteredSigns, currentPage), + [currentPage, filteredSigns], + ); const selectedSign = useMemo( () => signs.find((sign) => sign.id === selectedSignId) ?? null, [selectedSignId, signs], @@ -323,9 +329,25 @@ export function useSignLanguagePage(): SignLanguagePage { void saveSignOfWeek.mutateAsync(sign).catch(() => undefined); } + function updateSearchQuery(value: string) { + setSearchQuery(value); + setCurrentPage(1); + } + + function updateCategoryFilter(value: SignLanguageCategoryFilter) { + setCategoryFilter(value); + setCurrentPage(1); + } + + function updatePage(page: number) { + setCurrentPage(paginateSignLanguageItems(filteredSigns, page).currentPage); + } + return { signs, filteredSigns, + visibleSigns: pagination.items, + pagination, categories, filters, learnedSignIds, @@ -348,9 +370,10 @@ export function useSignLanguagePage(): SignLanguagePage { signsError: signsQuery.error || signOfWeekQuery.error, pageContentError: pageContentQuery.error, progressErrorMessage: getOptionalErrorMessage(progress.error), - setSearchQuery, - clearSearch: () => setSearchQuery(''), - setCategoryFilter, + setSearchQuery: updateSearchQuery, + clearSearch: () => updateSearchQuery(''), + setCategoryFilter: updateCategoryFilter, + setPage: updatePage, selectSign: setSelectedSignId, closeSign: () => setSelectedSignId(null), toggleLearned, diff --git a/frontend/src/business/sign-language/selectors.test.ts b/frontend/src/business/sign-language/selectors.test.ts index e66c614..cbd52e9 100644 --- a/frontend/src/business/sign-language/selectors.test.ts +++ b/frontend/src/business/sign-language/selectors.test.ts @@ -11,6 +11,7 @@ import { normalizeSignLanguageGifUrl, normalizeSignLanguageItems, normalizeSignLanguageYoutubeVideoUrl, + paginateSignLanguageItems, selectSignLanguageSignOfWeek, toSignLanguageCategoryFilter, } from '@/business/sign-language/selectors'; @@ -74,6 +75,34 @@ describe('sign language selectors', () => { })).toEqual([signs[0]]); }); + it('paginates signs at 12 cards per page', () => { + const manySigns = Array.from({ length: 25 }, (_, index) => ({ + ...signs[0], + id: `sign-${index + 1}`, + word: `Sign ${index + 1}`, + })); + + expect(paginateSignLanguageItems(manySigns, 1)).toMatchObject({ + currentPage: 1, + pageSize: 12, + totalItems: 25, + totalPages: 3, + startItem: 1, + endItem: 12, + }); + expect(paginateSignLanguageItems(manySigns, 1).items).toHaveLength(12); + expect(paginateSignLanguageItems(manySigns, 3).items.map((sign) => sign.id)).toEqual(['sign-25']); + expect(paginateSignLanguageItems(manySigns, 99).currentPage).toBe(3); + expect(paginateSignLanguageItems([], 1)).toMatchObject({ + currentPage: 1, + totalItems: 0, + totalPages: 1, + startItem: 0, + endItem: 0, + items: [], + }); + }); + it('returns zero progress when there are no signs', () => { expect(getSignLanguageProgressPercent([], new Set(['help']))).toBe(0); }); diff --git a/frontend/src/business/sign-language/selectors.ts b/frontend/src/business/sign-language/selectors.ts index 2ccf84b..1282c30 100644 --- a/frontend/src/business/sign-language/selectors.ts +++ b/frontend/src/business/sign-language/selectors.ts @@ -15,8 +15,11 @@ import { toWeekStartIso } from '@/shared/business/week'; import type { SignLanguageCategoryOption, SignLanguageFilters, + SignLanguagePagination, } from '@/business/sign-language/types'; +export const SIGN_LANGUAGE_PAGE_SIZE = 12; + const LIFEPRINT_HOST = new URL(SIGN_LANGUAGE_LIFEPRINT_URL).hostname; const YOUTUBE_HOST = 'youtube.com'; const YOUTUBE_SHORT_HOST = 'youtu.be'; @@ -105,6 +108,28 @@ export function filterSignLanguageItems( }); } +export function paginateSignLanguageItems( + signs: readonly SignItem[], + currentPage: number, + pageSize = SIGN_LANGUAGE_PAGE_SIZE, +): SignLanguagePagination { + const safePageSize = Math.max(1, Math.floor(pageSize)); + const totalPages = Math.max(1, Math.ceil(signs.length / safePageSize)); + const page = Math.min(Math.max(1, Math.floor(currentPage) || 1), totalPages); + const startIndex = (page - 1) * safePageSize; + const endIndex = startIndex + safePageSize; + + return { + currentPage: page, + pageSize: safePageSize, + totalItems: signs.length, + totalPages, + startItem: signs.length === 0 ? 0 : startIndex + 1, + endItem: Math.min(endIndex, signs.length), + items: signs.slice(startIndex, endIndex), + }; +} + export function getSignLanguageProgressPercent( signs: readonly SignItem[], learnedSignIds: ReadonlySet, diff --git a/frontend/src/business/sign-language/types.ts b/frontend/src/business/sign-language/types.ts index ece5efd..fa9ccaf 100644 --- a/frontend/src/business/sign-language/types.ts +++ b/frontend/src/business/sign-language/types.ts @@ -18,6 +18,16 @@ export interface SignLanguageFilters { readonly categoryFilter: SignLanguageCategoryFilter; } +export interface SignLanguagePagination { + readonly currentPage: number; + readonly pageSize: number; + readonly totalItems: number; + readonly totalPages: number; + readonly startItem: number; + readonly endItem: number; + readonly items: readonly SignItem[]; +} + export interface SignLanguageStepDraft { readonly step: number; readonly instruction: string; @@ -55,6 +65,8 @@ export interface SignLanguageVideoModalState { export interface SignLanguagePage { readonly signs: readonly SignItem[]; readonly filteredSigns: readonly SignItem[]; + readonly visibleSigns: readonly SignItem[]; + readonly pagination: SignLanguagePagination; readonly categories: readonly SignLanguageCategoryOption[]; readonly filters: SignLanguageFilters; readonly learnedSignIds: ReadonlySet; @@ -80,6 +92,7 @@ export interface SignLanguagePage { readonly setSearchQuery: (value: string) => void; readonly clearSearch: () => void; readonly setCategoryFilter: (value: SignLanguageCategoryFilter) => void; + readonly setPage: (page: number) => void; readonly selectSign: (id: string) => void; readonly closeSign: () => void; readonly toggleLearned: (id: string) => Promise; diff --git a/frontend/src/components/common/ImageUpload.tsx b/frontend/src/components/common/ImageUpload.tsx index 5699f34..7096b15 100644 --- a/frontend/src/components/common/ImageUpload.tsx +++ b/frontend/src/components/common/ImageUpload.tsx @@ -32,6 +32,7 @@ export function ImageUpload({ const inputRef = useRef(null); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); + const [failedImageUrl, setFailedImageUrl] = useState(null); async function handleSelect(file: File | undefined) { if (!file) return; @@ -39,6 +40,7 @@ export function ImageUpload({ setUploading(true); try { const privateUrl = await uploadFile(table, field, file); + setFailedImageUrl(null); onChange(privateUrl); } catch (err) { setError(getErrorMessage(err, 'Upload failed')); @@ -63,8 +65,13 @@ export function ImageUpload({ aria-label={`${label} upload in progress`} className="h-full w-full rounded-none bg-slate-700/70" /> - ) : value ? ( - {label} + ) : value && failedImageUrl !== value ? ( + {label} setFailedImageUrl(value)} + className="h-full w-full object-cover" + /> ) : ( )} diff --git a/frontend/src/components/common/TenantLogo.tsx b/frontend/src/components/common/TenantLogo.tsx index 9d30d3c..eeaf478 100644 --- a/frontend/src/components/common/TenantLogo.tsx +++ b/frontend/src/components/common/TenantLogo.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { fileAssetUrl } from '@/business/files/api'; import { cn } from '@/lib/utils'; @@ -15,6 +16,9 @@ export function TenantLogo({ imageClassName, }: TenantLogoProps) { const label = name?.trim() || 'Tenant'; + const [failedLogoUrl, setFailedLogoUrl] = useState(null); + const showImage = Boolean(logoUrl && failedLogoUrl !== logoUrl); + return (