# Airtable setup — enterprise manager portal

The enterprise manager portal (`/enterprise-admin`) lets an enterprise
customer's manager log in to the win2linux site, see their license pool,
assign codes to specific employees, and revoke them. It reads/writes the
existing `Codes` table and a new `Managers` table.

This is an extension of [`codes-airtable-setup.md`](codes-airtable-setup.md).
Read that one first.

## What changes

### 1. Two new fields on the existing `Codes` table

Add these to the `Codes` table (see `codes-airtable-setup.md` for the
existing fields):

| Field name      | Type     | Configuration                                            |
| --------------- | -------- | -------------------------------------------------------- |
| `Assigned to`   | Email    | Set when a manager assigns a code to a specific employee |
| `Assigned at`   | Date     | Include time. Set automatically by the assign function   |

The status lifecycle becomes:

```
unused ──assign──► unused (with Assigned to + Assigned at)
                │
                └──redeem──► redeemed
                            │
                            └──revoke──► revoked
```

Codes can be assigned but unredeemed (employee hasn't activated yet) or
unassigned and unredeemed (still in the free pool). The `Status` field
itself stays `unused` until the employee actually redeems on `/redeem`.

### 2. New `Managers` table

Create a new table named `Managers` in the same base. This stores the
identities allowed to log in to the manager portal for each customer.

| Field name        | Type              | Configuration                                                                                |
| ----------------- | ----------------- | -------------------------------------------------------------------------------------------- |
| `Email`           | Email             | Primary field. The manager's login.                                                          |
| `Customer`        | Single line text  | Must match `Customer` on the Codes table for this org (e.g. `Acme Corporation`)              |
| `Display name`    | Single line text  | Shown in the dashboard greeting (e.g. `Marie Dupont`)                                        |
| `Token hash`      | Single line text  | SHA-256 hex of the pending magic-link token. Cleared once the manager logs in.               |
| `Token expires`   | Date              | Include time. 1 hour after issue.                                                            |
| `Last login`      | Date              | Include time. Set on every successful magic-link verification.                               |
| `Created at`      | Date              | Include time. Set when the manager record is created.                                        |
| `Status`          | Single select     | Options: `active`, `disabled`. Default: `active`. Lets us suspend a manager without deleting. |

### 3. Three new Netlify environment variables

Add these alongside the existing `AIRTABLE_*` vars:

| Key                         | Value                                                                                | Notes                                                                                       |
| --------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `AIRTABLE_MANAGERS_TABLE`   | `Managers`                                                                           | The new table name. Override here if you want a different name.                              |
| `MANAGER_SESSION_SECRET`    | a random ≥ 32-byte string (`openssl rand -hex 32` or any password manager)           | Signs the session cookie. Rotate if leaked — invalidates all live sessions.                  |
| `RESEND_API_KEY`            | your Resend API key (optional)                                                       | If set, magic-link and assign emails are sent via Resend. If unset, they're logged + returned in the API response (dev mode). |
| `MANAGER_PORTAL_URL`        | `https://win2linux.org/enterprise-admin`                                             | Used to build the magic-link URL in emails. Defaults to `https://win2linux.org/enterprise-admin` if unset. |

Note: `MANAGER_SESSION_SECRET` is the only one that's mandatory for
production. Without `RESEND_API_KEY`, the portal still works — you just
won't get real emails.

After adding the env vars, **redeploy** so the Functions pick them up.

## Daily workflow

### Provisioning a new manager (when fulfilling an enterprise order)

1. Generate the codes as before (`scripts/generate-codes.py --customer "Acme Corp" --count 500`).
2. Open Airtable → `Managers` → **Add record** for the customer's named manager:
   - `Email`: `marie.dupont@acme.com`
   - `Customer`: `Acme Corporation` (must exactly match the value on the Codes records)
   - `Display name`: `Marie Dupont`
   - `Status`: `active`
   - `Created at`: now
3. Email the manager:
   - Their portal URL: `https://win2linux.org/enterprise-admin`
   - Tell them to enter their email — they'll get a magic link
   - Tell them they have N codes available

That's it. The manager logs in, the dashboard shows them their pool, and
they can self-serve from there.

### Adding a second manager for the same customer

Repeat the Airtable record-add for the new email. Both managers see the
same pool (the dashboard filters by `Customer`, not by individual record).

### Revoking a manager's access

Set their `Status` to `disabled` in Airtable. The next session they
attempt will fail at the magic-link step. Any active session cookie they
already hold expires on its own within 24 hours.

### Auditing

- **All actions**: filter the Codes table by `Customer` and group by
  `Status`. The `Assigned to`, `Assigned at`, `Redeemer email`, and
  `Redeemed at` fields tell the full story.
- **Logins**: filter the Managers table by `Customer`, look at `Last login`.
- **Code revocations**: filter Codes table by `Status = revoked`. Add
  notes in the `Notes` field for the audit trail (the function does not
  populate this — it's a manual annotation).

## Security model

| Concern                                       | Mitigation                                                                                                       |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Anyone can request a magic link for any email | Only Manager records with `Status = active` get a real link. Unknown emails get the same response (no enum leak). |
| Token replay                                  | Tokens are single-use. The `Token hash` field is cleared on first verify.                                        |
| Stolen session cookie                         | Cookie is HttpOnly + Secure + SameSite=Lax + signed HS256 + 24h expiry. Rotate `MANAGER_SESSION_SECRET` to kill all sessions. |
| Manager assigns codes for another customer    | Every function call re-fetches the manager's `Customer` from the JWT and filters Airtable by that `Customer`. The manager cannot see or modify other customers' rows. |
| Brute-forcing magic-link tokens               | 32-byte random tokens (256 bits) — infeasible. Plus rate-limit at the function layer (TODO Phase 2).            |

## Limitations (acknowledged, Phase 2 work)

- **No rate limiting** on the magic-link endpoint. Phase 2: add a Netlify Edge Function with a 5-per-hour-per-IP cap.
- **No email-change flow** for managers. To change a manager's email, edit the Airtable record directly.
- **No multi-org managers**. One row = one (manager, customer) pair. If the same person manages two customers, they need two Airtable records and two emails.
- **Soft-gate revocation** still applies (same as the codes system): a revoked code still works in the employee's already-loaded browser session until localStorage clears. The audit trail in Airtable is the legal record. JWT-based hard-gate is still on the Phase 2 roadmap (same one as for Codes).
