# 📄 Frontend Integration Guide - LINK Delivery, PDF Download & Email Resend

## Overview

The admin panel now supports **LINK-first delivery** for all forms, **secure token-backed PDF downloads**, and **resending failed email notifications**. `NewHire` additionally supports a secure package flow with uploads ZIP access, per-file downloads, and OTP step-up for sensitive content.

---

## 🎯 What the Admin Panel Can Do

1. **Download any form's PDF** — Stream the generated PDF directly to the browser
2. **Handle LINK-mode delivery** — Standard flows email a secure portal link instead of a PDF attachment
3. **Use the NewHire package routes** — ZIP upload package, per-file download, OTP request, OTP verify
4. **Resend failed emails** — Retry email delivery when the original send failed (operational fallback)
5. **See email status** — Every form submission response now includes `emailStatus` (`SENT`, `FAILED`, or `PENDING`)
6. **Get secure PDF URLs** — Form responses include a `pdfDownloadUrl` with an encrypted token (not a raw database ID)

---

## 📋 New API Endpoints

### Base URL: `/api/v1/admin/forms`

| Endpoint | Method | Description | Auth Required |
|----------|--------|-------------|---------------|
| `/{formType}/{formToken}/pdf` | `GET` | Download the PDF file | Admin, Office |
| `/{formType}/{formToken}/uploads.zip` | `GET` | Download the registered uploads ZIP (`NewHire` package flow) | Admin, Office |
| `/{formType}/{formToken}/files/{fileCategory}` | `GET` | Download a registered file category (`NewHire` package flow) | Admin, Office |
| `/{formType}/{formToken}/otp/request` | `POST` | Send an OTP code to the authenticated principal | Admin, Office |
| `/{formType}/{formToken}/otp/verify` | `POST` | Verify OTP and grant step-up access for sensitive package items | Admin, Office |
| `/{formType}/{formToken}/resend-email` | `POST` | Resend the email notification | Admin, Office |

> **Important:** `formToken` is an encrypted token, NOT a raw database ID. You receive the token from the form submission response's `pdfDownloadUrl` field.
>
> **Phase 6 note:** `resend-email` remains available as an operational fallback. Routine delivery is now `LINK` by default.

### Supported `formType` Values

| Form | URL Path Values (any alias works) |
|------|-----------------------------------|
| Bonus | `bonus` |
| Back Pay | `backpay`, `back-pay`, `back_pay` |
| Mileage | `mileage` |
| PTO | `pto` |
| Per Diem | `perdiem`, `per-diem`, `per_diem` |
| Non-MN Mileage | `nonmnmileage`, `non-mn-mileage`, `non_mn_mileage` |
| Termination | `termination` |
| Uniform | `uniform` |
| Target Order | `targetorder`, `target-order`, `target_order` |
| Work Ticket | `workticket`, `work-ticket`, `work_ticket` |
| Hotel | `hotel` |
| New Hire | `newhire`, `new-hire`, `new_hire` |
| Time Adjustment | `timeadjustment`, `time-adjustment`, `time_adjustment` |

---

## 🔄 Updated Form Submission Response

Every form submission now returns these **new fields**:

```typescript
interface FormSubmissionResponse {
  formName: string;         // e.g. "Bonus Form"
  success: boolean;
  apiError?: ApiError;
  // ✨ NEW FIELDS:
  formId: number;           // The saved entity ID
  pdfDownloadUrl: string;   // Encrypted URL, e.g. "/api/v1/admin/forms/bonus/aB3x...kQ/pdf"
  emailStatus: string;      // "SENT", "FAILED", or "PENDING"
}
```

### Example Response (Successful)

```json
{
  "formName": "Bonus Form",
  "success": true,
  "formId": 42,
  "pdfDownloadUrl": "/api/v1/admin/forms/bonus/aB3xR9_kLm2nP5qTs7vW...zY/pdf",
  "emailStatus": "SENT"
}
```

### Example Response (Email Failed, Form Saved)

```json
{
  "formName": "Bonus Form",
  "success": true,
  "formId": 42,
  "pdfDownloadUrl": "/api/v1/admin/forms/bonus/aB3xR9_kLm2nP5qTs7vW...zY/pdf",
  "emailStatus": "FAILED"
}
```

> **Key change:** `success: true` even when email fails — the form was saved to the database. The `emailStatus` field tells you whether the email succeeded.

---

## 🔐 Security Model

### Token-Based URLs (Not Raw IDs)

The `pdfDownloadUrl` contains an **AES-256-GCM encrypted token** instead of a plain database ID:

```
❌ Old: /api/v1/admin/forms/bonus/42/pdf          ← Raw ID, easy to enumerate
✅ New: /api/v1/admin/forms/bonus/aB3xR9_kL.../pdf  ← Encrypted, tamper-proof
```

**Why this matters:**
- An attacker cannot guess or enumerate other form IDs
- Each token is non-deterministic (same ID produces different tokens each time)
- Tampered tokens return `404 Not Found` (not `400 Bad Request` — prevents oracle attacks)
- Only the backend can decrypt tokens — the frontend treats them as opaque strings

### Required Roles

Both endpoints require the authenticated user to have `Admin` or `Office` authority. `DM` and `PS` roles will receive `403 Forbidden`.

For `NewHire` token-backed package access, sensitive resources may additionally require a successful OTP verification. Missing or expired step-up access returns `403 Forbidden` with the standard `ErrorResponse` contract.

### NewHire package routes

`NewHire` link delivery now issues a token-backed package with these route shapes:

```text
GET  /api/v1/admin/forms/new-hire/{formToken}/pdf
GET  /api/v1/admin/forms/new-hire/{formToken}/uploads.zip
GET  /api/v1/admin/forms/new-hire/{formToken}/files/{fileCategory}
POST /api/v1/admin/forms/new-hire/{formToken}/otp/request
POST /api/v1/admin/forms/new-hire/{formToken}/otp/verify
```

Supported sensitive `fileCategory` values currently include:
- `ID_BADGE`
- `GOV_ID_FRONT`
- `GOV_ID_REAR`
- `SSN_CARD`

All token-backed accesses are audited in `email_access_audit`, including OTP request/verify activity and token validation failures.

### Response Headers

PDF downloads include security headers:
- `Cache-Control: no-store` — Prevents PII from being cached
- `X-Content-Type-Options: nosniff` — Prevents MIME type sniffing
- `Content-Disposition: attachment` — Forces download (not inline display)

---

## 🎨 React Component Examples

### 1. Email Status Badge Component

```tsx
// components/EmailStatusBadge.tsx
interface EmailStatusBadgeProps {
  status: string;
}

export const EmailStatusBadge: React.FC<EmailStatusBadgeProps> = ({ status }) => {
  const config = {
    SENT: { label: '✓ Email Sent', className: 'badge-success' },
    FAILED: { label: '✗ Email Failed', className: 'badge-danger' },
    PENDING: { label: '⏳ Pending', className: 'badge-warning' },
  };

  const { label, className } = config[status] || config.PENDING;

  return <span className={`email-status-badge ${className}`}>{label}</span>;
};
```

```css
.email-status-badge {
  display: inline-block;
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 600;
}
.badge-success { background: #D1FAE5; color: #065F46; }
.badge-danger  { background: #FEE2E2; color: #991B1B; }
.badge-warning { background: #FEF3C7; color: #92400E; }
```

---

### 2. PDF Download Button

```tsx
// components/admin/PdfDownloadButton.tsx
import api from '../../services/axiosConfig';

interface PdfDownloadButtonProps {
  pdfDownloadUrl: string;  // The encrypted URL from form submission response
  formName: string;
}

export const PdfDownloadButton: React.FC<PdfDownloadButtonProps> = ({
  pdfDownloadUrl,
  formName
}) => {
  const [downloading, setDownloading] = useState(false);

  const handleDownload = async () => {
    setDownloading(true);
    try {
      const response = await api.get(pdfDownloadUrl, {
        responseType: 'blob',
      });

      // Create download link
      const blob = new Blob([response.data], { type: 'application/pdf' });
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;

      // Extract filename from Content-Disposition header, or use a fallback
      const contentDisposition = response.headers['content-disposition'];
      const filenameMatch = contentDisposition?.match(/filename="?(.+)"?/);
      link.download = filenameMatch ? filenameMatch[1] : `${formName}.pdf`;

      document.body.appendChild(link);
      link.click();
      link.remove();
      window.URL.revokeObjectURL(url);
    } catch (err: any) {
      if (err.response?.status === 404) {
        alert('PDF not found. The file may have been moved or deleted.');
      } else if (err.response?.status === 403) {
        alert('You do not have permission to download this PDF.');
      } else {
        alert('Failed to download PDF. Please try again.');
      }
      console.error('PDF download error:', err);
    } finally {
      setDownloading(false);
    }
  };

  return (
    <button
      onClick={handleDownload}
      disabled={downloading || !pdfDownloadUrl}
      className="btn-pdf-download"
    >
      {downloading ? '⏳ Downloading...' : '📄 Download PDF'}
    </button>
  );
};
```

---

### 3. Resend Email Button

```tsx
// components/admin/ResendEmailButton.tsx
import api from '../../services/axiosConfig';

interface ResendEmailButtonProps {
  formType: string;        // e.g. "bonus"
  formToken: string;       // The encrypted token extracted from pdfDownloadUrl
  onStatusUpdate: (newStatus: string) => void;
}

/**
 * Extract the encrypted token from a pdfDownloadUrl.
 * URL format: /api/v1/admin/forms/{formType}/{formToken}/pdf
 */
export function extractFormToken(pdfDownloadUrl: string): {
  formType: string;
  formToken: string;
} | null {
  const match = pdfDownloadUrl.match(
    /\/api\/v1\/admin\/forms\/([^/]+)\/([^/]+)\/pdf/
  );
  if (!match) return null;
  return { formType: match[1], formToken: match[2] };
}

export const ResendEmailButton: React.FC<ResendEmailButtonProps> = ({
  formType,
  formToken,
  onStatusUpdate,
}) => {
  const [sending, setSending] = useState(false);

  const handleResend = async () => {
    if (!confirm('Resend the email notification for this form?')) return;

    setSending(true);
    try {
      const response = await api.post(
        `/api/v1/admin/forms/${formType}/${formToken}/resend-email`
      );
      onStatusUpdate(response.data.emailStatus);
      alert('Email resent successfully!');
    } catch (err: any) {
      if (err.response?.status === 404) {
        alert('Form not found. The token may be invalid.');
      } else if (err.response?.status === 502) {
        onStatusUpdate('FAILED');
        alert('Email delivery failed. The email service may be unavailable.');
      } else if (err.response?.status === 403) {
        alert('You do not have permission to resend emails.');
      } else {
        alert('Failed to resend email. Please try again.');
      }
      console.error('Resend email error:', err);
    } finally {
      setSending(false);
    }
  };

  return (
    <button
      onClick={handleResend}
      disabled={sending}
      className="btn-resend-email"
    >
      {sending ? '⏳ Sending...' : '📧 Resend Email'}
    </button>
  );
};
```

---

### 4. Form Submission Row (Admin Table)

Combining all components into a row used in the admin forms table:

```tsx
// components/admin/FormSubmissionRow.tsx
import { EmailStatusBadge } from '../EmailStatusBadge';
import { PdfDownloadButton } from './PdfDownloadButton';
import { ResendEmailButton, extractFormToken } from './ResendEmailButton';

interface FormSubmission {
  formId: number;
  formName: string;
  emailStatus: string;
  pdfDownloadUrl: string;
  submittedDate: string;
  employeeName: string;
}

export const FormSubmissionRow: React.FC<{ form: FormSubmission }> = ({ form }) => {
  const [emailStatus, setEmailStatus] = useState(form.emailStatus);
  const tokenInfo = extractFormToken(form.pdfDownloadUrl);

  return (
    <tr>
      <td>{form.formName}</td>
      <td>{form.employeeName}</td>
      <td>{new Date(form.submittedDate).toLocaleString()}</td>
      <td>
        <EmailStatusBadge status={emailStatus} />
      </td>
      <td>
        <div className="action-buttons">
          <PdfDownloadButton
            pdfDownloadUrl={form.pdfDownloadUrl}
            formName={form.formName}
          />
          {emailStatus === 'FAILED' && tokenInfo && (
            <ResendEmailButton
              formType={tokenInfo.formType}
              formToken={tokenInfo.formToken}
              onStatusUpdate={setEmailStatus}
            />
          )}
        </div>
      </td>
    </tr>
  );
};
```

```css
.action-buttons {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.btn-pdf-download {
  padding: 6px 16px;
  background: #3B82F6;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s;
}
.btn-pdf-download:hover { background: #2563EB; }
.btn-pdf-download:disabled { background: #9CA3AF; cursor: not-allowed; }

.btn-resend-email {
  padding: 6px 16px;
  background: #F59E0B;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s;
}
.btn-resend-email:hover { background: #D97706; }
.btn-resend-email:disabled { background: #9CA3AF; cursor: not-allowed; }
```

---

### 5. Vue Composable (Alternative)

```typescript
// composables/useFormPdf.ts
import { ref } from 'vue';
import api from '@/services/axiosConfig';

export function useFormPdf() {
  const downloading = ref(false);
  const resending = ref(false);

  /**
   * Extract formType and formToken from a pdfDownloadUrl.
   */
  function extractTokenInfo(pdfDownloadUrl: string) {
    const match = pdfDownloadUrl.match(
      /\/api\/v1\/admin\/forms\/([^/]+)\/([^/]+)\/pdf/
    );
    if (!match) return null;
    return { formType: match[1], formToken: match[2] };
  }

  /**
   * Download a PDF using the encrypted URL.
   */
  async function downloadPdf(pdfDownloadUrl: string, fallbackFilename: string) {
    downloading.value = true;
    try {
      const response = await api.get(pdfDownloadUrl, { responseType: 'blob' });
      const blob = new Blob([response.data], { type: 'application/pdf' });
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;

      const cd = response.headers['content-disposition'];
      const match = cd?.match(/filename="?(.+)"?/);
      link.download = match ? match[1] : `${fallbackFilename}.pdf`;

      document.body.appendChild(link);
      link.click();
      link.remove();
      window.URL.revokeObjectURL(url);
    } finally {
      downloading.value = false;
    }
  }

  /**
   * Resend email using the encrypted token URL.
   * Returns the new email status.
   */
  async function resendEmail(
    formType: string,
    formToken: string
  ): Promise<string> {
    resending.value = true;
    try {
      const response = await api.post(
        `/api/v1/admin/forms/${formType}/${formToken}/resend-email`
      );
      return response.data.emailStatus;
    } finally {
      resending.value = false;
    }
  }

  return { downloading, resending, extractTokenInfo, downloadPdf, resendEmail };
}
```

**Usage in a Vue component:**

```vue
<template>
  <button @click="handleDownload" :disabled="downloading">
    {{ downloading ? '⏳ Downloading...' : '📄 Download PDF' }}
  </button>
  <button
    v-if="emailStatus === 'FAILED'"
    @click="handleResend"
    :disabled="resending"
  >
    {{ resending ? '⏳ Sending...' : '📧 Resend Email' }}
  </button>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useFormPdf } from '@/composables/useFormPdf';

const props = defineProps<{
  pdfDownloadUrl: string;
  formName: string;
}>();

const { downloading, resending, extractTokenInfo, downloadPdf, resendEmail } = useFormPdf();
const emailStatus = ref(props.initialEmailStatus);

async function handleDownload() {
  await downloadPdf(props.pdfDownloadUrl, props.formName);
}

async function handleResend() {
  const info = extractTokenInfo(props.pdfDownloadUrl);
  if (!info) return;
  const newStatus = await resendEmail(info.formType, info.formToken);
  emailStatus.value = newStatus;
}
</script>
```

---

## ⚠️ Error Responses

| HTTP Status | Meaning | Frontend Action |
|-------------|---------|-----------------|
| `200` | Success | Download PDF / Show updated status |
| `401` | JWT expired | Axios interceptor auto-refreshes |
| `403` | Insufficient role | Show "Access denied" message |
| `404` | Invalid token or form not found | Show "Form not found" message |
| `502` | Email delivery failed (resend) | Show "Email failed" and allow retry |

All error responses follow the standard `ErrorResponse` format:
```json
{
  "status": 404,
  "message": "Form not found",
  "timestamp": "2026-04-04T13:30:00"
}
```

---

## 🧪 Testing

### Quick Browser Console Test

```javascript
// After logging in as Admin or Office user:

// 1. Submit a form and capture the response
const submitResponse = await api.post('/api/v1/finance/bonusSubmit', formData);
console.log(submitResponse.data.pdfDownloadUrl);  // e.g. "/api/v1/admin/forms/bonus/aB3x.../pdf"
console.log(submitResponse.data.emailStatus);      // "SENT" or "FAILED"

// 2. Download the PDF using the URL from the response
const pdfResponse = await api.get(submitResponse.data.pdfDownloadUrl, {
  responseType: 'blob'
});
// pdfResponse.data is the PDF blob

// 3. If email failed, resend it
// Extract token from URL: /api/v1/admin/forms/bonus/{TOKEN}/pdf
const url = submitResponse.data.pdfDownloadUrl;
const resendUrl = url.replace('/pdf', '/resend-email').replace('/pdf', '');
// Or better — parse it:
const parts = url.match(/\/api\/v1\/admin\/forms\/([^/]+)\/([^/]+)\/pdf/);
const resendResponse = await api.post(
  `/api/v1/admin/forms/${parts[1]}/${parts[2]}/resend-email`
);
console.log(resendResponse.data.emailStatus);  // "SENT"
```

---

## 📦 Integration Checklist

- [ ] Update form submission handlers to read `pdfDownloadUrl` and `emailStatus` from response
- [ ] Add `EmailStatusBadge` component to display status in admin forms list
- [ ] Add `PdfDownloadButton` component (uses `responseType: 'blob'` for download)
- [ ] Add `ResendEmailButton` component (shown only when `emailStatus === 'FAILED'`)
- [ ] Add `extractFormToken()` helper to parse formType/formToken from `pdfDownloadUrl`
- [ ] Handle 404 errors gracefully (invalid/expired tokens)
- [ ] Handle 403 errors for non-Admin/Office users
- [ ] Handle 502 errors for email delivery failures
- [ ] Test PDF download with Admin role ✅
- [ ] Test PDF download with Office role ✅
- [ ] Test PDF download with DM role (should get 403) ✅
- [ ] Test email resend flow end-to-end ✅

---

## 💡 Key Integration Notes

1. **Treat `pdfDownloadUrl` as opaque** — Don't parse or construct it manually. Store it alongside the form submission data and use it as-is for downloads.

2. **The token is NOT the form ID** — Don't try to extract the database ID from it. The `formId` field is provided separately if you need the raw ID for display.

3. **Tokens are non-deterministic** — Submitting the same form twice produces different tokens. Each token is unique.

4. **Store the URL at submission time** — The `pdfDownloadUrl` in the response is the only time you receive the token. Store it in your frontend state/store when you get the form submission response.

5. **Blob download required** — Use `responseType: 'blob'` in axios for PDF downloads. The response is a binary PDF file, not JSON.

---

*PDF & Email Resend — Frontend Integration Guide*
*Date: April 4, 2026*

