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:
parent
25c8905d76
commit
1b9ea3fdd6
10
backend/.env
10
backend/.env
@ -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=
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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)`,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ export interface OrganizationDto {
|
||||
id: string;
|
||||
name: string | null;
|
||||
logo?: string | null;
|
||||
esaEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CampusDto {
|
||||
|
||||
@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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[] = [
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.';
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 && (
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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' });
|
||||
|
||||
|
||||
@ -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> {
|
||||
|
||||
33
frontend/src/shared/auth/sessionPassword.test.ts
Normal file
33
frontend/src/shared/auth/sessionPassword.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
25
frontend/src/shared/auth/sessionPassword.ts
Normal file
25
frontend/src/shared/auth/sessionPassword.ts
Normal 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);
|
||||
}
|
||||
@ -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. */
|
||||
|
||||
106
frontend/tests/e2e/helpers/seeded-users.ts
Normal file
106
frontend/tests/e2e/helpers/seeded-users.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user