fixed mailing (show password in the UI if mailer doesnt configure, password visibility in the forms, ESA visibility for organisations, SignLenguage pagination.

This commit is contained in:
Dmitri 2026-06-26 17:29:10 +02:00
parent 25c8905d76
commit 1b9ea3fdd6
56 changed files with 1299 additions and 219 deletions

View File

@ -1 +1,9 @@
PORT=8080
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 <app@flatlogic.app>
EMAIL_HOST=email-smtp.us-east-1.amazonaws.com
EMAIL_PORT=587
EMAIL_USER=
EMAIL_PASS=

View File

@ -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 <app@example.com>
EMAIL_HOST=
EMAIL_FROM=School Chain Manager <app@flatlogic.app>
EMAIL_HOST=email-smtp.us-east-1.amazonaws.com
EMAIL_PORT=587
EMAIL_USER=
EMAIL_PASS=

View File

@ -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.

View File

@ -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

View File

@ -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 |

View File

@ -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<string>` 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 <app@flatlogic.app>"
EMAIL_HOST=email-smtp.us-east-1.amazonaws.com
EMAIL_PORT=587
EMAIL_USER=<smtp-user>
EMAIL_PASS=<smtp-password>
```
`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

View File

@ -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: <UserInput> }`. Creates the user then
sends an invitation email.
- `POST /api/users` -> `200` `{ id, organizationId, temporaryPassword? }`. Request body:
`{ data: <UserInput> }`. 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

View File

@ -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;

View File

@ -59,7 +59,7 @@ export type AuthenticatedUser = InferAttributes<Users> & {
/** 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;

View File

@ -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"));

View File

@ -0,0 +1,31 @@
import { DataTypes, type QueryInterface } from 'sequelize';
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
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');
}
},
};

View File

@ -37,6 +37,7 @@ export class Organizations extends Model<
declare id: CreationOptional<string>;
declare name: string | null;
declare logo: CreationOptional<string | null>;
declare esaEnabled: CreationOptional<boolean>;
declare importHash: CreationOptional<string | null>;
declare createdAt: CreationOptional<Date>;
declare updatedAt: CreationOptional<Date>;
@ -285,6 +286,12 @@ name: {
logo: { type: DataTypes.TEXT },
esaEnabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,

View File

@ -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<Organizations>[] = [
{ 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)`,

View File

@ -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);

View File

@ -17,6 +17,7 @@ export interface OrganizationDto {
id: string;
name: string | null;
logo?: string | null;
esaEnabled: boolean;
}
export interface CampusDto {

View File

@ -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' },
);
});
});

View File

@ -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) {

View File

@ -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);
});

View File

@ -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(

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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=<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=<student role id>` 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

View File

@ -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;

View File

@ -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: `<role>@flatlogic.com` / `flatlogicUser123!`
- Secondary tenant users: `<role>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

View File

@ -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),

View File

@ -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[] = [

View File

@ -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) {

View File

@ -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.');
});
});

View File

@ -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<CurrentUser, 'classId'> | 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<CurrentUser, 'app_role' | 'classId'> | 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.';
}

View File

@ -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<SignLanguageCategoryFilter>('all');
const [currentPage, setCurrentPage] = useState(1);
const [selectedSignId, setSelectedSignId] = useState<string | null>(null);
const [signDraft, setSignDraft] = useState<SignLanguageDraft | null>(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,

View File

@ -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);
});

View File

@ -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<string>,

View File

@ -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<string>;
@ -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<void>;

View File

@ -32,6 +32,7 @@ export function ImageUpload({
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [failedImageUrl, setFailedImageUrl] = useState<string | null>(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 ? (
<img src={fileAssetUrl(value)} alt={label} className="h-full w-full object-cover" />
) : value && failedImageUrl !== value ? (
<img
src={fileAssetUrl(value)}
alt={label}
onError={() => setFailedImageUrl(value)}
className="h-full w-full object-cover"
/>
) : (
<ImagePlus size={previewSize === 'lg' ? 28 : 20} />
)}

View File

@ -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<string | null>(null);
const showImage = Boolean(logoUrl && failedLogoUrl !== logoUrl);
return (
<span
className={cn(
@ -23,10 +27,11 @@ export function TenantLogo({
)}
aria-hidden="true"
>
{logoUrl ? (
{showImage && logoUrl ? (
<img
src={fileAssetUrl(logoUrl)}
alt=""
onError={() => setFailedLogoUrl(logoUrl)}
className={cn('h-full w-full object-cover', imageClassName)}
/>
) : (

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { fileDownloadUrl } from '@/business/files/api';
import { fileAssetUrl } from '@/business/files/api';
import { cn } from '@/lib/utils';
interface UserAvatarProps {
@ -37,7 +37,7 @@ export function UserAvatar({
>
{showImage && avatarUrl ? (
<img
src={fileDownloadUrl(avatarUrl)}
src={fileAssetUrl(avatarUrl)}
alt=""
onError={() => setFailedAvatarUrl(avatarUrl)}
className={cn('h-full w-full object-cover', imageClassName)}

View File

@ -1,6 +1,8 @@
import { useState } from 'react';
import { useScope } from '@/business/scope/hooks';
import { getTenantInitials } from '@/business/scope/selectors';
import { useAuth } from '@/contexts/useAuth';
import { fileAssetUrl } from '@/business/files/api';
interface TenantBadgeProps {
readonly collapsed?: boolean;
@ -14,15 +16,18 @@ interface TenantBadgeProps {
export function TenantBadge({ collapsed = false }: TenantBadgeProps) {
const { user } = useAuth();
const { tierLabel, activeTenant } = useScope(user);
const [failedLogoUrl, setFailedLogoUrl] = useState<string | null>(null);
if (!activeTenant) {
return null;
}
const mark = activeTenant.logo ? (
const showLogo = Boolean(activeTenant.logo && failedLogoUrl !== activeTenant.logo);
const mark = showLogo && activeTenant.logo ? (
<img
src={activeTenant.logo}
src={fileAssetUrl(activeTenant.logo)}
alt=""
onError={() => setFailedLogoUrl(activeTenant.logo)}
className="w-full h-full object-cover rounded-lg"
/>
) : (

View File

@ -23,8 +23,6 @@ export function SignLanguageHeader({ canPersistProgress }: SignLanguageHeaderPro
)}
icon={HandMetal}
iconClassName="bg-gradient-to-br from-indigo-400 to-indigo-600"
titleClassName="text-gray-800"
descriptionClassName="text-gray-500"
/>
);
}

View File

@ -0,0 +1,78 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { SignLanguagePagination as SignLanguagePaginationModel } from '@/business/sign-language/types';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface SignLanguagePaginationProps {
readonly pagination: SignLanguagePaginationModel;
readonly onPageChange: (page: number) => void;
}
function getVisiblePages(currentPage: number, totalPages: number): readonly number[] {
const start = Math.max(1, Math.min(currentPage - 1, totalPages - 2));
const end = Math.min(totalPages, start + 2);
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
}
export function SignLanguagePagination({
pagination,
onPageChange,
}: SignLanguagePaginationProps) {
if (pagination.totalItems === 0) {
return null;
}
const visiblePages = getVisiblePages(pagination.currentPage, pagination.totalPages);
return (
<nav
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-700/70 bg-slate-900/70 px-4 py-3"
aria-label="Sign language pagination"
>
<p className="text-sm text-slate-300">
Showing {pagination.startItem}-{pagination.endItem} of {pagination.totalItems} signs
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.currentPage === 1}
onClick={() => onPageChange(pagination.currentPage - 1)}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<div className="flex items-center gap-1" aria-label="Pages">
{visiblePages.map((page) => (
<button
key={page}
type="button"
aria-current={page === pagination.currentPage ? 'page' : undefined}
onClick={() => onPageChange(page)}
className={cn(
'h-8 min-w-8 rounded-md border px-2 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950',
page === pagination.currentPage
? 'border-indigo-400 bg-indigo-500 text-white'
: 'border-slate-700 bg-slate-950/60 text-slate-300 hover:bg-slate-800',
)}
>
{page}
</button>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.currentPage === pagination.totalPages}
onClick={() => onPageChange(pagination.currentPage + 1)}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</nav>
);
}

View File

@ -6,6 +6,7 @@ import { SignLanguageFilters } from '@/components/sign-language/SignLanguageFilt
import { SignLanguageGrid } from '@/components/sign-language/SignLanguageGrid';
import { SignLanguageHeader } from '@/components/sign-language/SignLanguageHeader';
import { SignLanguageManagementPanel } from '@/components/sign-language/SignLanguageManagementPanel';
import { SignLanguagePagination } from '@/components/sign-language/SignLanguagePagination';
import { SignLanguageProgressPanel } from '@/components/sign-language/SignLanguageProgressPanel';
import { SignLanguageRememberPanel } from '@/components/sign-language/SignLanguageRememberPanel';
import { SignLanguageVideoModal } from '@/components/sign-language/SignLanguageVideoModal';
@ -64,14 +65,20 @@ export function SignLanguageView({ page }: SignLanguageViewProps) {
Please try again.
</StatePanel>
) : (
<SignLanguageGrid
signs={page.filteredSigns}
learnedSignIds={page.learnedSignIds}
canManageSigns={page.canManageSigns}
onSelectSign={page.selectSign}
onEditSign={page.startEditSign}
onDeleteSign={page.requestDeleteSign}
/>
<div className="space-y-4">
<SignLanguageGrid
signs={page.visibleSigns}
learnedSignIds={page.learnedSignIds}
canManageSigns={page.canManageSigns}
onSelectSign={page.selectSign}
onEditSign={page.startEditSign}
onDeleteSign={page.requestDeleteSign}
/>
<SignLanguagePagination
pagination={page.pagination}
onPageChange={page.setPage}
/>
</div>
)}
{page.selectedSign && (

View File

@ -37,11 +37,11 @@ export function AttendanceModule({ userRole }: AttendanceModuleProps) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center"><Clock size={20} className="text-white" /></div>
Attendance
</h2>
<p className="text-sm text-gray-500 mt-1">{isDirector ? 'Campus-level attendance dashboard with trends' : 'Your personal attendance snapshot'}</p>
<p className="text-sm text-slate-400 mt-1">{isDirector ? 'Campus-level attendance dashboard with trends' : 'Your personal attendance snapshot'}</p>
</div>
<div className="bg-orange-50 rounded-2xl border border-orange-200 p-4 flex items-center gap-3">
<Shield size={18} className="text-orange-600 flex-shrink-0" />

View File

@ -1,6 +1,6 @@
import type { FormEvent } from 'react';
import { useMemo, useState } from 'react';
import { ClipboardList, FileCheck, KeyRound, Loader2, UserCircle } from 'lucide-react';
import { ClipboardList, Eye, EyeOff, FileCheck, KeyRound, Loader2, UserCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -34,6 +34,11 @@ import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
import { canZoneCheckIn } from '@/business/zone-checkin/selectors';
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
import {
readRememberedCurrentPassword,
rememberCurrentPassword,
} from '@/shared/auth/sessionPassword';
import { cn } from '@/lib/utils';
interface StatusMessage {
readonly type: 'success' | 'error';
@ -73,6 +78,51 @@ function ReadOnlyField({ label, value }: { label: string; value: string }) {
);
}
interface PasswordFieldProps {
readonly id: string;
readonly label: string;
readonly value: string;
readonly autoComplete: string;
readonly showPassword: boolean;
readonly onChange: (value: string) => void;
readonly onTogglePassword: () => void;
}
function PasswordField({
id,
label,
value,
autoComplete,
showPassword,
onChange,
onTogglePassword,
}: PasswordFieldProps) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-slate-100">{label}</Label>
<div className="relative">
<Input
id={id}
type={showPassword ? 'text' : 'password'}
autoComplete={autoComplete}
value={value}
className={`${formControlClassName} pr-10`}
onChange={(event) => onChange(event.target.value)}
required
/>
<button
type="button"
onClick={onTogglePassword}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition-colors hover:text-slate-100"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
);
}
export default function ProfilePage() {
const { user, profile, refreshUser } = useAuth();
const capabilitiesQuery = useIamCapabilities();
@ -96,9 +146,10 @@ export default function ProfilePage() {
const [profileSaving, setProfileSaving] = useState(false);
const [profileStatus, setProfileStatus] = useState<StatusMessage | null>(null);
const [currentPassword, setCurrentPassword] = useState('');
const [currentPassword, setCurrentPassword] = useState(() => readRememberedCurrentPassword());
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPasswordFields, setShowPasswordFields] = useState(false);
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordStatus, setPasswordStatus] = useState<StatusMessage | null>(null);
@ -108,6 +159,9 @@ export default function ProfilePage() {
const [organizationLogo, setOrganizationLogo] = useState<string | null>(
user?.organizations?.logo ?? null,
);
const [organizationEsaEnabled, setOrganizationEsaEnabled] = useState(
user?.organizations?.esaEnabled !== false,
);
const [organizationSaving, setOrganizationSaving] = useState(false);
const [organizationStatus, setOrganizationStatus] = useState<StatusMessage | null>(null);
@ -212,7 +266,8 @@ export default function ProfilePage() {
setPasswordSaving(true);
try {
await changePassword(currentPassword, newPassword);
setCurrentPassword('');
rememberCurrentPassword(newPassword);
setCurrentPassword(newPassword);
setNewPassword('');
setConfirmPassword('');
setPasswordStatus({ type: 'success', text: 'Password updated.' });
@ -233,6 +288,7 @@ export default function ProfilePage() {
await updateOrganization(organizationId, {
name: organizationName.trim(),
logo: organizationLogo ?? undefined,
esaEnabled: organizationEsaEnabled,
});
await refreshUser();
setOrganizationStatus({ type: 'success', text: 'Organization profile updated.' });
@ -394,6 +450,41 @@ export default function ProfilePage() {
</div>
</div>
<div className={`${formPanelClassName} space-y-4`}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-2xl">
<p className="text-sm font-semibold text-slate-100">ESA funding section</p>
<p className="mt-1 text-xs text-slate-300">
Show ESA Funding Info for this organization and allow campus ESA content management.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={organizationEsaEnabled}
onClick={() => setOrganizationEsaEnabled((current) => !current)}
className={cn(
'relative inline-flex h-7 w-12 shrink-0 rounded-full border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950',
organizationEsaEnabled
? 'border-emerald-400/60 bg-emerald-500'
: 'border-slate-600 bg-slate-800',
)}
>
<span
className={cn(
'mt-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
organizationEsaEnabled ? 'translate-x-6' : 'translate-x-0.5',
)}
/>
</button>
</div>
<p className="text-xs text-slate-400">
{organizationEsaEnabled
? 'ESA Funding Info is visible to users with ESA permission in this organization.'
: 'ESA Funding Info is hidden for this organization until it is enabled again.'}
</p>
</div>
<StatusBanner status={organizationStatus} />
<div className="flex justify-end">
@ -543,43 +634,34 @@ export default function ProfilePage() {
Use your current password before setting a new one.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="currentPassword" className="text-slate-100">Current password</Label>
<Input
id="currentPassword"
type="password"
autoComplete="current-password"
value={currentPassword}
className={formControlClassName}
onChange={(event) => setCurrentPassword(event.target.value)}
required
/>
</div>
<PasswordField
id="currentPassword"
label="Current password"
autoComplete="current-password"
value={currentPassword}
showPassword={showPasswordFields}
onChange={setCurrentPassword}
onTogglePassword={() => setShowPasswordFields((current) => !current)}
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="newPassword" className="text-slate-100">New password</Label>
<Input
id="newPassword"
type="password"
autoComplete="new-password"
value={newPassword}
className={formControlClassName}
onChange={(event) => setNewPassword(event.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirmPassword" className="text-slate-100">Confirm new password</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
value={confirmPassword}
className={formControlClassName}
onChange={(event) => setConfirmPassword(event.target.value)}
required
/>
</div>
<PasswordField
id="newPassword"
label="New password"
autoComplete="new-password"
value={newPassword}
showPassword={showPasswordFields}
onChange={setNewPassword}
onTogglePassword={() => setShowPasswordFields((current) => !current)}
/>
<PasswordField
id="confirmPassword"
label="Confirm new password"
autoComplete="new-password"
value={confirmPassword}
showPassword={showPasswordFields}
onChange={setConfirmPassword}
onTogglePassword={() => setShowPasswordFields((current) => !current)}
/>
</div>
</div>
<StatusBanner status={passwordStatus} />

View File

@ -1,7 +1,7 @@
import type { FormEvent } from 'react';
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, GraduationCap, Loader2, Pencil, Plus, UserPlus, Users, X } from 'lucide-react';
import { ChevronDown, Copy, GraduationCap, Loader2, Pencil, Plus, UserPlus, Users, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -12,6 +12,7 @@ import { ModuleHeader } from '@/components/ui/module-header';
import { NativeSelect } from '@/components/ui/native-select';
import { PageSkeleton } from '@/components/ui/page-skeleton';
import { UserAvatar } from '@/components/common/UserAvatar';
import { useScopeContext } from '@/contexts/scope-context';
import { useAuth } from '@/contexts/useAuth';
import { usePermissions } from '@/hooks/usePermissions';
import {
@ -28,7 +29,9 @@ import {
buildMyClassStudentSaveData,
buildMyClassGuardianSaveData,
canManageMyClassStudents,
getMyClassUnavailableMessage,
hasMyClassGuardianFormValues,
resolveMyClassClassId,
type MyClassGuardianFormValues,
type MyClassStudentFormValues,
} from '@/business/my-class/selectors';
@ -45,6 +48,11 @@ interface StatusMessage {
readonly text: string;
}
interface CreatedCredential {
readonly email: string;
readonly password: string;
}
const emptyStudentForm = (): MyClassStudentFormValues => ({
namePrefix: '',
firstName: '',
@ -104,14 +112,16 @@ type GuardianStudentLink = NonNullable<Awaited<ReturnType<typeof listGuardianStu
export default function MyClassPage() {
const { user } = useAuth();
const { effectiveTenant } = useScopeContext();
const permissions = usePermissions();
const queryClient = useQueryClient();
const classId = user?.classId ?? null;
const classId = resolveMyClassClassId(user, effectiveTenant);
const [studentForm, setStudentForm] = useState<MyClassStudentFormValues>(() => emptyStudentForm());
const [editingStudentId, setEditingStudentId] = useState<string | null>(null);
const [isStudentFormOpen, setIsStudentFormOpen] = useState(false);
const [studentFormSaving, setStudentFormSaving] = useState(false);
const [studentFormStatus, setStudentFormStatus] = useState<StatusMessage | null>(null);
const [createdCredentials, setCreatedCredentials] = useState<CreatedCredential[]>([]);
const classQuery = useQuery({
queryKey: ['my-class', classId],
@ -189,6 +199,7 @@ export default function MyClassPage() {
const updateStudentForm = (patch: Partial<MyClassStudentFormValues>) => {
setStudentForm((current) => ({ ...current, ...patch }));
setStudentFormStatus(null);
setCreatedCredentials([]);
};
const updateGuardianForm = (guardianKey: string, patch: Partial<MyClassGuardianFormValues>) => {
setStudentForm((current) => ({
@ -198,6 +209,7 @@ export default function MyClassPage() {
)),
}));
setStudentFormStatus(null);
setCreatedCredentials([]);
};
const addGuardianForm = () => {
setStudentForm((current) => ({
@ -205,6 +217,7 @@ export default function MyClassPage() {
guardians: [...current.guardians, emptyGuardianForm()],
}));
setStudentFormStatus(null);
setCreatedCredentials([]);
};
const removeGuardianForm = (guardianKey: string) => {
setStudentForm((current) => ({
@ -214,16 +227,19 @@ export default function MyClassPage() {
: current.guardians.filter((guardian) => guardian.key !== guardianKey),
}));
setStudentFormStatus(null);
setCreatedCredentials([]);
};
const resetStudentForm = () => {
setStudentForm(emptyStudentForm());
setEditingStudentId(null);
setIsStudentFormOpen(false);
setCreatedCredentials([]);
};
const startCreateStudent = () => {
setStudentFormStatus(null);
setCreatedCredentials([]);
if (isStudentFormOpen && !editingStudentId) {
resetStudentForm();
return;
@ -238,12 +254,14 @@ export default function MyClassPage() {
setStudentForm(studentFormFromRow(student, guardians));
setEditingStudentId(student.id);
setStudentFormStatus(null);
setCreatedCredentials([]);
setIsStudentFormOpen(true);
};
const handleStudentSubmit = async (event: FormEvent) => {
event.preventDefault();
setStudentFormStatus(null);
setCreatedCredentials([]);
if (!classId || !studentRoleId || !guardianRoleId) {
setStudentFormStatus({ type: 'error', text: 'Student access is not available for this class.' });
@ -260,19 +278,34 @@ export default function MyClassPage() {
setStudentFormSaving(true);
try {
let studentId = editingStudentId;
const credentials: CreatedCredential[] = [];
if (editingStudentId) {
await updateUser(editingStudentId, payload);
} else {
const created = await createUser(payload);
studentId = created.id;
if (created.temporaryPassword) {
credentials.push({
email: studentForm.email.trim(),
password: created.temporaryPassword,
});
}
}
if (studentId) {
for (const guardian of guardiansToSave) {
const guardianPayload = buildMyClassGuardianSaveData(guardian, guardianRoleId);
const guardianId = guardian.id
? guardian.id
: (await createUser(guardianPayload)).id;
let guardianId = guardian.id;
if (!guardianId) {
const createdGuardian = await createUser(guardianPayload);
guardianId = createdGuardian.id;
if (createdGuardian.temporaryPassword) {
credentials.push({
email: guardian.email.trim(),
password: createdGuardian.temporaryPassword,
});
}
}
if (guardian.id) {
await updateUser(guardian.id, guardianPayload);
}
@ -288,9 +321,16 @@ export default function MyClassPage() {
]);
setStudentFormStatus({
type: 'success',
text: editingStudentId ? 'Student updated.' : 'Student created.',
text: editingStudentId
? 'Student updated.'
: credentials.length > 0
? 'Student created. Copy the temporary password before leaving this form.'
: 'Student created.',
});
resetStudentForm();
setCreatedCredentials(credentials);
if (editingStudentId || credentials.length === 0) {
resetStudentForm();
}
} catch (error) {
setStudentFormStatus({ type: 'error', text: getErrorMessage(error, 'Could not save student') });
} finally {
@ -298,11 +338,21 @@ export default function MyClassPage() {
}
};
function copyCreatedPassword(password: string) {
void navigator.clipboard?.writeText(password);
setStudentFormStatus({
type: 'success',
text: 'Temporary password copied. Give it to the created user.',
});
}
if (!classId) {
const message = getMyClassUnavailableMessage({ user, effectiveTenant });
return (
<div className="p-4 md:p-6">
<div className="rounded-lg border border-slate-700/50 bg-slate-800/40 px-4 py-3 text-sm text-slate-300">
You are not assigned to a class yet.
{message}
</div>
</div>
);
@ -558,6 +608,45 @@ export default function MyClassPage() {
))}
</div>
{createdCredentials.length > 0 && (
<div className="rounded-lg border border-amber-400/50 bg-amber-950/30 p-4 text-amber-50">
<p className="text-sm font-semibold">Temporary passwords</p>
<p className="mt-1 text-xs text-amber-100/85">
Mailer is not configured, so these passwords are shown only once here. Copy them now and give
each password to the matching student or guardian so they can sign in and change it from their profile.
</p>
<div className="mt-3 space-y-3">
{createdCredentials.map((credential) => (
<div
key={credential.email}
className="grid gap-3 rounded-md border border-amber-400/30 bg-slate-950/45 p-3 md:grid-cols-[1fr_auto]"
>
<div className="space-y-1.5">
<Label htmlFor={`created-password-${credential.email}`} className="text-amber-50">
{credential.email}
</Label>
<Input
id={`created-password-${credential.email}`}
value={credential.password}
readOnly
className="border-amber-400/50 bg-slate-950/80 font-mono text-amber-50"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => copyCreatedPassword(credential.password)}
className="self-end border-amber-300/60 text-amber-50 hover:bg-amber-400/10"
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
</div>
))}
</div>
</div>
)}
<div className="flex justify-end">
<Button
type="submit"

View File

@ -1,7 +1,7 @@
import type { FormEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowDown, ArrowUp, ArrowUpDown, Check, ChevronDown, Loader2, Pencil, Search, Trash2, UserPlus, Users, X } from 'lucide-react';
import { ArrowDown, ArrowUp, ArrowUpDown, Check, ChevronDown, Copy, Loader2, Pencil, Search, Trash2, UserPlus, Users, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -61,6 +61,11 @@ interface StatusMessage {
readonly text: string;
}
interface CreatedCredential {
readonly email: string;
readonly password: string;
}
type UserListSortField =
| 'name'
| 'email'
@ -241,6 +246,7 @@ export default function UserAdminPage() {
const [excludePerms, setExcludePerms] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<StatusMessage | null>(null);
const [createdCredential, setCreatedCredential] = useState<CreatedCredential | null>(null);
const editingSuperAdmin = editingId !== null && role === 'super_admin';
const roleOptions = editingSuperAdmin ? ['super_admin' as UserRole] : createRoleOptions;
@ -432,6 +438,7 @@ export default function UserAdminPage() {
setGrantPerms([]);
setExcludePerms([]);
setIsUserFormOpen(false);
setCreatedCredential(null);
}
function startEdit(row: AdminUserRow) {
@ -455,6 +462,7 @@ export default function UserAdminPage() {
setGrantPerms((row.custom_permissions ?? []).map((p) => p.id));
setExcludePerms((row.custom_permissions_filter ?? []).map((p) => p.id));
setStatus(null);
setCreatedCredential(null);
}
function buildTenantPayload(): SaveUserData {
@ -473,6 +481,7 @@ export default function UserAdminPage() {
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setStatus(null);
setCreatedCredential(null);
if (!selectedRole) return;
if (needsPicker && !resolvedTenantId) {
@ -501,21 +510,33 @@ export default function UserAdminPage() {
setSaving(true);
try {
let returnedTemporaryPassword: string | undefined;
if (editingId) {
await updateUser(editingId, data);
} else {
const { id } = selectedRole === 'owner' && !data.organizations
const { id, temporaryPassword } = selectedRole === 'owner' && !data.organizations
? await createOwnerWithOrganization(data)
: await createUser(data);
returnedTemporaryPassword = temporaryPassword;
if (tenantInput === 'guardian' && id && studentIds.length > 0) {
await Promise.all(studentIds.map((sid) => linkGuardianStudent(id, sid)));
}
if (temporaryPassword) {
setCreatedCredential({ email: data.email ?? email.trim(), password: temporaryPassword });
}
}
await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
await queryClient.invalidateQueries({ queryKey: ['guardian-students'] });
setUsersPage(0);
setStatus({ type: 'success', text: editingId ? 'User updated.' : 'User created (invite sent).' });
resetForm();
if (editingId) {
setStatus({ type: 'success', text: 'User updated.' });
resetForm();
} else if (returnedTemporaryPassword) {
setStatus({ type: 'success', text: 'User created.' });
} else {
setStatus({ type: 'success', text: 'User created (invite sent).' });
resetForm();
}
} catch (error) {
setStatus({ type: 'error', text: getErrorMessage(error, 'Could not save user') });
} finally {
@ -523,6 +544,15 @@ export default function UserAdminPage() {
}
}
function copyCreatedPassword() {
if (!createdCredential) return;
void navigator.clipboard?.writeText(createdCredential.password);
setStatus({
type: 'success',
text: 'Temporary password copied. Give it to the created user.',
});
}
async function handleDelete(row: AdminUserRow) {
setStatus(null);
try {
@ -870,6 +900,38 @@ export default function UserAdminPage() {
</details>
)}
{createdCredential && (
<div className="rounded-lg border border-amber-400/50 bg-amber-950/30 p-4 text-amber-50">
<p className="text-sm font-semibold">Temporary password</p>
<p className="mt-1 text-xs text-amber-100/85">
Mailer is not configured, so this password is shown only once here. Copy it now and give it to
the created user so they can sign in and change it from their profile.
</p>
<div className="mt-3 grid gap-3 md:grid-cols-[1fr_auto]">
<div className="space-y-1.5">
<Label htmlFor="created-user-password" className="text-amber-50">
{createdCredential.email}
</Label>
<Input
id="created-user-password"
value={createdCredential.password}
readOnly
className="border-amber-400/50 bg-slate-950/80 font-mono text-amber-50"
/>
</div>
<Button
type="button"
variant="outline"
onClick={copyCreatedPassword}
className="self-end border-amber-300/60 text-amber-50 hover:bg-amber-400/10"
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
</div>
</div>
)}
<div className="flex justify-end">
<Button
type="submit"

View File

@ -3,6 +3,7 @@ import { apiRequest } from '@/shared/api/httpClient';
export interface CreateOrganizationData {
readonly name: string;
readonly logo?: string | null;
readonly esaEnabled?: boolean;
}
export interface CreateSchoolData {

View File

@ -46,6 +46,20 @@ describe('users API', () => {
});
});
it('returns temporary passwords from create responses when the mailer is disabled', async () => {
vi.mocked(apiRequest).mockResolvedValueOnce({
id: 'user-1',
organizationId: 'org-1',
temporaryPassword: 'temporary-password',
});
await expect(createUser({ email: 'user@example.com' })).resolves.toEqual({
id: 'user-1',
organizationId: 'org-1',
temporaryPassword: 'temporary-password',
});
});
it('creates owners with organizations', async () => {
vi.mocked(apiRequest).mockResolvedValueOnce({ id: 'user-1' });

View File

@ -15,7 +15,7 @@ export interface AdminUserRow {
readonly email: string;
readonly avatar?: readonly { readonly privateUrl?: string | null }[];
readonly app_role?: { id: string; name: string | null } | null;
readonly organizations?: { id: string; name?: string | null; logo?: string | null } | null;
readonly organizations?: { id: string; name?: string | null; logo?: string | null; esaEnabled?: boolean } | null;
readonly school?: { id: string; name?: string | null; logo?: string | null } | null;
readonly campus?: { id: string; name?: string | null; logo?: string | null } | null;
readonly class?: { id: string; name?: string | null; logo?: string | null } | null;
@ -92,6 +92,7 @@ export function listUsers(
export interface CreateUserResult {
readonly id: string | null;
readonly organizationId?: string | null;
readonly temporaryPassword?: string;
}
export async function createUser(data: SaveUserData): Promise<CreateUserResult> {

View File

@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
forgetRememberedCurrentPassword,
readRememberedCurrentPassword,
rememberCurrentPassword,
} from '@/shared/auth/sessionPassword';
describe('session password helper', () => {
beforeEach(() => {
window.sessionStorage.clear();
vi.unstubAllGlobals();
});
it('remembers the current password only in session storage', () => {
rememberCurrentPassword('current-password');
expect(readRememberedCurrentPassword()).toBe('current-password');
forgetRememberedCurrentPassword();
expect(readRememberedCurrentPassword()).toBe('');
});
it('treats unavailable browser storage as empty', () => {
vi.stubGlobal('window', undefined);
rememberCurrentPassword('current-password');
expect(readRememberedCurrentPassword()).toBe('');
expect(() => forgetRememberedCurrentPassword()).not.toThrow();
});
});

View File

@ -0,0 +1,25 @@
const SESSION_PASSWORD_KEY = 'school-chain-current-password';
function canUseSessionStorage(): boolean {
if (typeof window === 'undefined') return false;
try {
return Boolean(window.sessionStorage);
} catch {
return false;
}
}
export function rememberCurrentPassword(password: string): void {
if (!canUseSessionStorage()) return;
window.sessionStorage.setItem(SESSION_PASSWORD_KEY, password);
}
export function readRememberedCurrentPassword(): string {
if (!canUseSessionStorage()) return '';
return window.sessionStorage.getItem(SESSION_PASSWORD_KEY) ?? '';
}
export function forgetRememberedCurrentPassword(): void {
if (!canUseSessionStorage()) return;
window.sessionStorage.removeItem(SESSION_PASSWORD_KEY);
}

View File

@ -11,6 +11,7 @@ export interface BackendOrganization {
readonly id: string;
readonly name?: string;
readonly logo?: string | null;
readonly esaEnabled?: boolean;
}
/** Minimal tenant identity (school/class) for the active-scope badge + selectors. */

View File

@ -0,0 +1,106 @@
import { expect, type APIRequestContext } from '@playwright/test';
export const BACKEND_API_URL =
process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api';
const ADMIN_EMAIL = 'admin@flatlogic.com';
const ADMIN_PASSWORD = 'flatlogicAdmin123!';
interface RoleRow {
readonly id: string;
readonly name: string | null;
}
export interface CreatedTestUser {
readonly id: string;
readonly email: string;
readonly password: string;
readonly organizationId: string | null;
}
interface CreateUserResponse {
readonly id?: string;
readonly organizationId?: string | null;
readonly temporaryPassword?: string;
}
export async function loginAsAdmin(request: APIRequestContext): Promise<void> {
const res = await request.post(`${BACKEND_API_URL}/auth/signin/local`, {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(res.ok(), 'admin login for e2e setup must succeed').toBe(true);
}
export async function signOut(request: APIRequestContext): Promise<void> {
await request.post(`${BACKEND_API_URL}/auth/signout`);
}
export async function roleIdByName(
request: APIRequestContext,
roleName: string,
): Promise<string> {
const rolesRes = await request.get(`${BACKEND_API_URL}/roles`);
expect(rolesRes.status()).toBe(200);
const rolesBody = (await rolesRes.json()) as { rows?: RoleRow[] };
const role = (rolesBody.rows ?? []).find((row) => row.name === roleName);
expect(role, `seeded ${roleName} role must exist`).toBeTruthy();
return role!.id;
}
export async function createTestUser(
request: APIRequestContext,
params: {
readonly roleName: string;
readonly email: string;
readonly organizationId?: string | null;
},
): Promise<CreatedTestUser> {
const roleId = await roleIdByName(request, params.roleName);
const payload: Record<string, unknown> = {
email: params.email,
app_role: roleId,
};
if (params.organizationId !== undefined) {
payload.organizations = params.organizationId;
}
const createRes = await request.post(`${BACKEND_API_URL}/users`, {
data: { data: payload },
});
expect(createRes.ok(), `create ${params.roleName} test user`).toBe(true);
const created = (await createRes.json()) as CreateUserResponse;
expect(created.id, `created ${params.roleName} user id`).toBeTruthy();
expect(
created.temporaryPassword,
`created ${params.roleName} temporary password`,
).toBeTruthy();
return {
id: created.id!,
email: params.email,
password: created.temporaryPassword!,
organizationId: created.organizationId ?? params.organizationId ?? null,
};
}
export async function deleteTestUsers(
request: APIRequestContext,
users: readonly CreatedTestUser[],
): Promise<void> {
await loginAsAdmin(request);
for (const user of users) {
await request.delete(`${BACKEND_API_URL}/users/${user.id}`);
}
}
export async function deleteOrganizations(
request: APIRequestContext,
organizationIds: readonly (string | null | undefined)[],
): Promise<void> {
await loginAsAdmin(request);
for (const organizationId of organizationIds) {
if (organizationId) {
await request.delete(`${BACKEND_API_URL}/organizations/${organizationId}`);
}
}
}

View File

@ -1,4 +1,10 @@
import { expect, type Page, test } from '@playwright/test';
import {
BACKEND_API_URL,
deleteOrganizations,
deleteTestUsers,
type CreatedTestUser,
} from './helpers/seeded-users';
/**
* Scoped provisioning e2e (Workstream 8 / §3.4, §3.7). Proves the onboarding
@ -11,9 +17,6 @@ import { expect, type Page, test } from '@playwright/test';
const ADMIN_PASSWORD = 'flatlogicAdmin123!';
const ADMIN_EMAIL = 'admin@flatlogic.com';
const BACKEND_API_URL =
process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api';
interface RoleRow {
readonly id: string;
readonly name: string | null;
@ -23,6 +26,11 @@ interface UserRow {
readonly email: string | null;
readonly organizationId: string | null;
}
interface CreateUserResponse {
readonly id?: string;
readonly organizationId?: string | null;
readonly temporaryPassword?: string;
}
async function login(page: Page, email: string, password: string): Promise<void> {
await page.goto('/login');
@ -39,32 +47,52 @@ test.describe('Scoped provisioning', () => {
page,
}) => {
await login(page, ADMIN_EMAIL, ADMIN_PASSWORD);
let createdUser: CreatedTestUser | null = null;
// Resolve the seeded `owner` role id.
const rolesRes = await page.request.get(`${BACKEND_API_URL}/roles`);
expect(rolesRes.status()).toBe(200);
const rolesBody = (await rolesRes.json()) as { rows?: RoleRow[] };
const ownerRole = (rolesBody.rows ?? []).find((r) => r.name === 'owner');
expect(ownerRole, 'seeded owner role must exist').toBeTruthy();
try {
// Resolve the seeded `owner` role id.
const rolesRes = await page.request.get(`${BACKEND_API_URL}/roles`);
expect(rolesRes.status()).toBe(200);
const rolesBody = (await rolesRes.json()) as { rows?: RoleRow[] };
const ownerRole = (rolesBody.rows ?? []).find((r) => r.name === 'owner');
expect(ownerRole, 'seeded owner role must exist').toBeTruthy();
// Create a brand-new owner with no organization.
const email = `provisioned-owner-${Date.now()}@example.com`;
const createRes = await page.request.post(`${BACKEND_API_URL}/users`, {
data: { data: { email, app_role: ownerRole!.id } },
});
expect(createRes.ok()).toBe(true);
// Create a brand-new owner with no organization.
const email = `provisioned-owner-${Date.now()}@example.com`;
const createRes = await page.request.post(`${BACKEND_API_URL}/users`, {
data: { data: { email, app_role: ownerRole!.id } },
});
expect(createRes.ok()).toBe(true);
const createBody = (await createRes.json()) as CreateUserResponse;
expect(createBody.id, 'created owner id').toBeTruthy();
createdUser = {
id: createBody.id!,
email,
password: createBody.temporaryPassword ?? '',
organizationId: createBody.organizationId ?? null,
};
// The owner now belongs to an auto-created company.
const lookupRes = await page.request.get(
`${BACKEND_API_URL}/users?email=${encodeURIComponent(email)}`,
);
expect(lookupRes.status()).toBe(200);
const lookupBody = (await lookupRes.json()) as { rows?: UserRow[] };
const created = (lookupBody.rows ?? []).find((u) => u.email === email);
expect(created, 'the provisioned owner must exist').toBeTruthy();
expect(
created!.organizationId,
'owner-create must auto-create and link a company',
).toBeTruthy();
// The owner now belongs to an auto-created company.
const lookupRes = await page.request.get(
`${BACKEND_API_URL}/users?email=${encodeURIComponent(email)}`,
);
expect(lookupRes.status()).toBe(200);
const lookupBody = (await lookupRes.json()) as { rows?: UserRow[] };
const created = (lookupBody.rows ?? []).find((u) => u.email === email);
expect(created, 'the provisioned owner must exist').toBeTruthy();
expect(
created!.organizationId,
'owner-create must auto-create and link a company',
).toBeTruthy();
createdUser = {
...createdUser,
organizationId: created!.organizationId,
};
} finally {
if (createdUser) {
await deleteTestUsers(page.request, [createdUser]);
await deleteOrganizations(page.request, [createdUser.organizationId]);
}
}
});
});

View File

@ -1,4 +1,11 @@
import { expect, type Page, test } from '@playwright/test';
import {
createTestUser,
deleteOrganizations,
deleteTestUsers,
loginAsAdmin,
type CreatedTestUser,
} from './helpers/seeded-users';
/**
* Authenticated RBAC access e2e (Workstream 8 / §3.6). Uses the seeded per-role
@ -13,9 +20,6 @@ const ADMIN_EMAIL = 'admin@flatlogic.com';
const BACKEND_API_URL =
process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api';
// Seeded fixture user ids (see backend `shared/constants/seed-fixtures.ts`).
const OWNER_ID = 'b1a7c0de-0000-4000-8000-000000000012';
/** Denied responses may be 400 (permission middleware) or 403 (relational policy). */
const DENIED = [400, 401, 403];
@ -69,6 +73,30 @@ test.describe('RBAC route access', () => {
});
test.describe('RBAC API enforcement', () => {
let testOwner: CreatedTestUser;
let testSuperintendent: CreatedTestUser;
test.beforeAll(async ({ request }) => {
const suffix = Date.now();
await loginAsAdmin(request);
testOwner = await createTestUser(request, {
roleName: 'owner',
email: `e2e-rbac-owner-${suffix}@example.com`,
organizationId: null,
});
expect(testOwner.organizationId, 'owner create should provision an organization').toBeTruthy();
testSuperintendent = await createTestUser(request, {
roleName: 'superintendent',
email: `e2e-rbac-superintendent-${suffix}@example.com`,
organizationId: testOwner.organizationId,
});
});
test.afterAll(async ({ request }) => {
await deleteTestUsers(request, [testSuperintendent, testOwner].filter(Boolean));
await deleteOrganizations(request, [testOwner?.organizationId]);
});
test('the super admin can list users', async ({ page }) => {
await login(page, ADMIN_EMAIL, ADMIN_PASSWORD);
const res = await page.request.get(`${BACKEND_API_URL}/users`);
@ -86,8 +114,8 @@ test.describe('RBAC API enforcement', () => {
test('a superintendent cannot delete the owner (relational policy)', async ({
page,
}) => {
await login(page, 'superintendent@flatlogic.com', USER_PASSWORD);
const res = await page.request.delete(`${BACKEND_API_URL}/users/${OWNER_ID}`);
await login(page, testSuperintendent.email, testSuperintendent.password);
const res = await page.request.delete(`${BACKEND_API_URL}/users/${testOwner.id}`);
expect(DENIED).toContain(res.status());
});
});

View File

@ -1,4 +1,12 @@
import { expect, type APIResponse, type Page, test } from '@playwright/test';
import {
BACKEND_API_URL,
createTestUser,
deleteOrganizations,
deleteTestUsers,
loginAsAdmin,
type CreatedTestUser,
} from './helpers/seeded-users';
/**
* Cross-tenant isolation e2e (Workstream 8 / Workstream 2). Proves a non-global
@ -9,14 +17,6 @@ import { expect, type APIResponse, type Page, test } from '@playwright/test';
* Requires the backend running with the database migrated + seeded, and the
* seed passwords in the environment.
*/
const USER_PASSWORD = 'flatlogicUser123!';
const BACKEND_API_URL =
process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api';
const PRIMARY_OWNER_EMAIL = 'owner@flatlogic.com';
const SECONDARY_OWNER_EMAIL = 'owner2@flatlogic.com';
const ACADEMIC_YEARS = `${BACKEND_API_URL}/academic_years`;
interface AcademicYearRow {
@ -55,50 +55,84 @@ async function findByName(
}
test.describe('Cross-tenant isolation', () => {
let primaryOwner: CreatedTestUser;
let secondaryOwner: CreatedTestUser;
test.beforeAll(async ({ request }) => {
const suffix = Date.now();
await loginAsAdmin(request);
primaryOwner = await createTestUser(request, {
roleName: 'owner',
email: `e2e-primary-owner-${suffix}@example.com`,
organizationId: null,
});
secondaryOwner = await createTestUser(request, {
roleName: 'owner',
email: `e2e-secondary-owner-${suffix}@example.com`,
organizationId: null,
});
});
test.afterAll(async ({ request }) => {
await deleteTestUsers(request, [secondaryOwner, primaryOwner].filter(Boolean));
await deleteOrganizations(request, [
secondaryOwner?.organizationId,
primaryOwner?.organizationId,
]);
});
test('a tenant cannot read, list, mutate, or delete another tenant record', async ({
page,
}) => {
const rivalName = `Rival Year ${Date.now()}`;
let rivalId: string | null = null;
// 1. owner2 (Rival Academy) creates a record in its own tenant.
await login(page, SECONDARY_OWNER_EMAIL, USER_PASSWORD);
const created = await page.request.post(ACADEMIC_YEARS, {
data: { data: { name: rivalName } },
});
expect(created.status()).toBe(200);
try {
// 1. The secondary tenant owner creates a record in its own tenant.
await login(page, secondaryOwner.email, secondaryOwner.password);
const created = await page.request.post(ACADEMIC_YEARS, {
data: { data: { name: rivalName } },
});
expect(created.status()).toBe(200);
const rival = await findByName(page, rivalName);
expect(rival, 'owner2 should see its own record').toBeTruthy();
const rivalId = rival!.id;
const rival = await findByName(page, rivalName);
expect(rival, 'secondary owner should see its own record').toBeTruthy();
rivalId = rival!.id;
// 2. owner (Demo Academy) must not see it in any way.
await login(page, PRIMARY_OWNER_EMAIL, USER_PASSWORD);
// 2. The primary tenant owner must not see it in any way.
await login(page, primaryOwner.email, primaryOwner.password);
// 2a. List isolation.
const primaryRows = await listRows(page);
expect(primaryRows.some((row) => row.id === rivalId)).toBe(false);
// 2a. List isolation.
const primaryRows = await listRows(page);
expect(primaryRows.some((row) => row.id === rivalId)).toBe(false);
// 2b. Read-by-id isolation: tenant-scoped read returns no record.
const readRes = await page.request.get(`${ACADEMIC_YEARS}/${rivalId}`);
const readBody = await readRes.text();
expect(readBody).not.toContain(rivalName);
// 2b. Read-by-id isolation: tenant-scoped read returns no record.
const readRes = await page.request.get(`${ACADEMIC_YEARS}/${rivalId}`);
const readBody = await readRes.text();
expect(readBody).not.toContain(rivalName);
// 2c. Update isolation: a tenant-scoped update finds nothing error.
const updateRes: APIResponse = await page.request.put(
`${ACADEMIC_YEARS}/${rivalId}`,
{ data: { id: rivalId, data: { name: 'Hijacked' } } },
);
expect(updateRes.ok()).toBe(false);
// 2c. Update isolation: a tenant-scoped update finds nothing -> error.
const updateRes: APIResponse = await page.request.put(
`${ACADEMIC_YEARS}/${rivalId}`,
{ data: { id: rivalId, data: { name: 'Hijacked' } } },
);
expect(updateRes.ok()).toBe(false);
// 2d. Delete isolation: attempt to delete the rival record.
await page.request.delete(`${ACADEMIC_YEARS}/${rivalId}`);
// 2d. Delete isolation: attempt to delete the rival record.
await page.request.delete(`${ACADEMIC_YEARS}/${rivalId}`);
// 3. owner2 confirms its record still exists and is unchanged.
await login(page, SECONDARY_OWNER_EMAIL, USER_PASSWORD);
const stillThere = await findByName(page, rivalName);
expect(
stillThere,
'the rival record must survive the cross-tenant update/delete attempts',
).toBeTruthy();
// 3. The secondary owner confirms its record still exists and is unchanged.
await login(page, secondaryOwner.email, secondaryOwner.password);
const stillThere = await findByName(page, rivalName);
expect(
stillThere,
'the rival record must survive the cross-tenant update/delete attempts',
).toBeTruthy();
} finally {
if (rivalId) {
await login(page, secondaryOwner.email, secondaryOwner.password);
await page.request.delete(`${ACADEMIC_YEARS}/${rivalId}`);
}
}
});
});