# Send a student into the panel

> Add a button to your site that signs a student into an organization's panel. Mint a single-use link on click and redirect at once, in Express and plain PHP.

Your students already sign in to your own site. In this tutorial you add an **Open my exam panel**
button that takes a student straight into an organization's panel, signed in as themselves, with no
password to remember. You build it twice, as an Express route and as a plain PHP script, and handle
every way the API can refuse.

The API provides a sign-in link. This tutorial covers the one safe way to hand that link to a student.

## How a sign-in link behaves

| Property | Value |
|---|---|
| Request | `POST /v1/<organizationId>/auth/signin` with `{ "studentId": "…", "redirect": "…" }` |
| Permission | `auth/signin` on that organization |
| Lifetime | 120 seconds from the moment it is issued (`expiresIn` in the response) |
| Uses | One. The first request that opens it spends it |
| Who it signs in | Whoever opens it, as the student |
| What the student needs | Access to that organization. No password, and no confirmed email address |
| First use | Creates the organization's own copy of the student |

That last row matters beyond this button. Applications, supervisor links, certificates and reports on
an organization all need the organization's copy of the student, which exists only after their first
sign-in there. See [Organizations](https://hub.main-team.org/api/organizations).

## The pattern: mint on click, redirect at once

1. The student presses a button on your page. The button submits a form to your server with `POST`.
2. Your server works out who the student is from **your own session**, and looks up the core student id
   you stored when you registered them.
3. Your server asks the API for a link.
4. Your server answers `303 See Other` with the link in `Location`, and `Cache-Control: no-store`.
5. The browser follows it at once. After a short pass through the Main Team sign-in address, the
   student lands in the organization's panel, signed in.

Why each part matters:

- **Mint on click, not on page load.** A link minted when your page renders is useless two minutes
  later, and until then it sits in your HTML as a working credential.
- **`POST`, not a plain link.** Browsers, chat and email link previews, and security scanners fetch
  plain links ahead of time. If one of them triggers your route, it mints a link and may spend it
  before the student arrives. A form sent with `POST` goes only when the student presses the button.
- **Redirect, don't display.** A URL shown on screen gets copied into chats and emails, and any
  preview of it spends it.
- **Never log, email or store the URL.** Anyone who opens it within the 120 seconds is signed in as the
  student. Log the `request_id` instead; it is all support needs.

**Take the student id from your session, never from the request.** If your route accepted
`?studentId=` from the browser, any signed-in student could sign in as any other student you
registered. The routes below have no student id in their URL at all.

## Before you start

- **Permissions:** `auth/signin` on each organization you send students to. To diagnose a refusal
  (below) you also need `student/read` on `mto`, and to fix one, `student/update` on the organization
  (or on `mto` for the flat route). See [Permissions](https://hub.main-team.org/api/permissions).
- **The core student id** for each of your users, stored when you registered them with
  `POST /v1/student`. See [Register and apply](https://hub.main-team.org/api/tutorials/register-and-apply#step-4-register-the-student).
- **The organization ids**, from `GET /v1/organization`. They never change, so load them once.
- **The helper file** `mainteam.mjs` or `mainteam.php` from
  [Register and apply](https://hub.main-team.org/api/tutorials/register-and-apply#the-helper-file). It signs tokens, retries a
  `429` after `Retry-After`, and turns refusals into an `ApiError` with `status`, `code` and
  `requestId`.
- Node.js 20 or newer with Express (`npm install express`), or PHP 8.1 or newer with sessions.

## The request

```bash [curl]
curl -s -X POST https://api.main-team.org/v1/<organizationId>/auth/signin \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "studentId": "66e6a3f5c1d2b30012a4f9c1" }'
```

```json [Response 200]
{
  "success": true,
  "message": "Sign-in link generated successfully.",
  "data": {
    "url": "https://auth.main-team.org/api/user/oauth/invoke?accessToken=q8x2…&redirectUrl=…",
    "organization": "stem",
    "studentId": "66e6a3f5c1d2b30012a4f9c1",
    "expiresIn": 120
  }
}
```

Treat `url` as opaque: redirect to it exactly as it came, and never build or change one yourself.
`organization` is the organization's slug and `studentId` is the id you sent.

### Choosing where the student lands

`redirect` is optional. Leave it out and the student lands on the organization's home page. To land
somewhere else in the organization's panel, send a path on that panel:

| `redirect` | Result |
|---|---|
| *(absent)* | The organization's home page |
| `/` | The root of the organization's panel |
| `/dashboard` | Accepted |
| `/exams?tab=upcoming` | Accepted |
| `dashboard` | Refused: no leading `/` |
| `https://example.com/` | Refused: not a path |
| `//example.com` | Refused: a second `/` would leave the site |
| `/\example.com` | Refused: backslashes are not allowed anywhere |
| `/my exams` | Refused: whitespace is not allowed |

A refused value answers `400 bad_request` with
`redirect must be a site-relative path starting with "/" (e.g. "/dashboard")`. The rule keeps every
link inside the organization's own panel; a link can never send the student to another site.

**Straight to a group challenge.** A group challenge's page is where the student's group uploads its
work. [`getGroupChallengeStudent`](https://hub.main-team.org/api/reference/get-group-challenge-student) and the student list
give its path as `panelPath`, with `{userId}` left in it for the link to fill. Send it as is:

```json
{
  "studentId": "<studentId>",
  "redirect": "/stadia/{userId}/group-challenges/6650a1b2c3d4e5f6a7b8c9eb"
}
```

See [Group challenges](https://hub.main-team.org/api/guides/group-challenges#your-students).

## The Express route

Save this next to `mainteam.mjs` as `server.mjs`. Replace the stand-in authentication with your own.

```js [server.mjs]
// server.mjs: an "Open my exam panel" button for your own students.
// Node.js 20 or newer, Express 4 or 5 (npm install express), and mainteam.mjs next to it.
import express from 'express';
import { api, ApiError } from './mainteam.mjs';

const app = express();

// Replace this with your real authentication. It must put the signed-in user
// on req.user, including the core student id you stored at registration.
app.use((req, res, next) => {
  req.user = { mainTeamStudentId: '66e6a3f5c1d2b30012a4f9c1' };
  next();
});

// Organization ids never change: load them once at start-up.
const organizationIds = new Map(); // slug -> _id

async function loadOrganizations() {
  const res = await api('GET', '/organization?limit=100');
  for (const org of res.data) organizationIds.set(org.slug, org._id);
}

app.get('/', (req, res) => {
  res.type('html').send(`<!doctype html>
<form method="post" action="/panel/stem">
  <button type="submit">Open my STEM exam panel</button>
</form>`);
});

app.post('/panel/:slug', async (req, res) => {
  const organizationId = organizationIds.get(req.params.slug);
  if (!organizationId) return res.status(404).type('text').send('Unknown organization.');

  // From your session, never from the request.
  const studentId = req.user.mainTeamStudentId;

  try {
    const link = await api('POST', `/${organizationId}/auth/signin`, { studentId });
    res.set('Cache-Control', 'no-store');
    return res.redirect(303, link.data.url); // never log link.data.url
  } catch (error) {
    const message = await explainRefusal(error, studentId, req.params.slug);
    const status = error instanceof ApiError && error.status === 403 ? 403 : 502;
    return res.status(status).type('text').send(message);
  }
});

// Turns a refusal into a message for the student, and logs what your staff need.
async function explainRefusal(error, studentId, slug) {
  if (!(error instanceof ApiError)) {
    console.error(`sign-in link failed: ${error.message}`);
    return 'The exam panel cannot be reached right now. Please try again in a minute.';
  }
  console.error(`sign-in link refused: ${error.status} ${error.code} request ${error.requestId}`);

  if (error.status === 403) {
    // Two causes share this status: the student has no access to this organization,
    // or this API account lacks auth/signin there. The student's record tells them apart.
    const student = (await api('GET', `/student/${studentId}`).catch(() => null))?.data;
    const platforms = student?.activatedPlatformsThisSeason ?? [];
    if (student && !platforms.includes('common') && !platforms.includes(slug)) {
      return 'You are not entered for this organization yet. Please contact your school.';
    }
    return 'The exam panel is not available from this site at the moment. We have been notified.';
  }
  if (error.status === 404) {
    return 'We could not find your exam account. Please contact your school.';
  }
  return 'The exam panel cannot be reached right now. Please try again in a minute.';
}

await loadOrganizations();
app.listen(3000, () => console.log('Listening on port 3000'));
```

Run it with `node server.mjs`, open port 3000 of that machine in your browser and press the button. Your browser's
network tab shows one `POST /panel/stem` answered `303`, then the sign-in hand-off, then the panel.

## The plain PHP route

Two files: the page with the button, and the script it posts to. Both assume your own login has
already put the student's core id in the session as `mainteam_student_id`.

```php [index.php]
<?php
// index.php: the page with the button. Your own login has already run.
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(16));
?>
<!doctype html>
<form method="post" action="/panel.php?org=stem">
  <input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
  <button type="submit">Open my STEM exam panel</button>
</form>
```

```php [panel.php]
<?php
// panel.php: POST /panel.php?org=stem signs the current student into that organization's panel.
declare(strict_types=1);
require __DIR__ . '/mainteam.php';

session_start();

// Organization ids never change: keep the slug => _id map in your configuration.
const ORGANIZATION_IDS = [
    'stem' => '64b7f1c2a9e3d45f10c2b7a1',
];

function fail(int $status, string $message): never
{
    http_response_code($status);
    header('Content-Type: text/plain; charset=utf-8');
    echo $message;
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Allow: POST');
    fail(405, 'Please use the button.');
}
$csrf = $_POST['csrf'] ?? null;
if (!is_string($csrf) || !hash_equals($_SESSION['csrf'] ?? '', $csrf)) {
    fail(400, 'Please reload the page and try again.');
}

// From your session, never from the request.
$studentId = $_SESSION['mainteam_student_id'] ?? null;
if (!is_string($studentId)) {
    fail(401, 'Please sign in first.');
}

$slug = (string) ($_GET['org'] ?? '');
$organizationId = ORGANIZATION_IDS[$slug] ?? null;
if ($organizationId === null) {
    fail(404, 'Unknown organization.');
}

$api = MainTeam::fromEnv();

try {
    $link = $api->call('POST', "/$organizationId/auth/signin", ['studentId' => $studentId]);
    header('Cache-Control: no-store');
    header('Location: ' . $link['data']['url'], true, 303); // never log this URL
    exit;
} catch (ApiError $e) {
    error_log("sign-in link refused: {$e->status} {$e->errorCode} request {$e->requestId}");

    if ($e->status === 403) {
        // Two causes share this status: the student has no access to this organization,
        // or this API account lacks auth/signin there. The student's record tells them apart.
        try {
            $student = $api->call('GET', "/student/$studentId")['data'] ?? null;
        } catch (ApiError) {
            $student = null;
        }
        $platforms = $student['activatedPlatformsThisSeason'] ?? [];
        if ($student !== null && !in_array('common', $platforms, true) && !in_array($slug, $platforms, true)) {
            fail(403, 'You are not entered for this organization yet. Please contact your school.');
        }
        fail(403, 'The exam panel is not available from this site at the moment. We have been notified.');
    }
    if ($e->status === 404) {
        fail(404, 'We could not find your exam account. Please contact your school.');
    }
    fail(502, 'The exam panel cannot be reached right now. Please try again in a minute.');
} catch (RuntimeException $e) {
    error_log('sign-in link failed: ' . $e->getMessage());
    fail(502, 'The exam panel cannot be reached right now. Please try again in a minute.');
}
```

The hidden `csrf` field is ordinary cross-site request protection for a form that acts on the
signed-in user. If your framework already protects forms, use its mechanism instead.

## When the link is refused

| Status | Message | Cause | What to do |
|---|---|---|---|
| `403 forbidden` | `Student is not activated for organization stem.` | The student's `activatedPlatformsThisSeason` holds neither `common` nor this organization's slug | Grant access (below), then let the student press the button again |
| `403 forbidden` | `Insufficient role permissions` | Your account lacks `auth/signin` on this organization | Ask your operator for the role. Retrying does not help |
| `404 not_found` | `Student not found!` | The id is not one of your students. A student of another account, a mistyped id and a missing student all answer the same | Check the id stored against this user |
| `404 not_found` | `Organization not found!` | The organization id in the path is wrong | Reload your organization ids |
| `400 bad_request` | `studentId must be a mongodb id` | The stored id is not a 24-character hex id | Fix the stored id |
| `400 bad_request` | `redirect must be a site-relative path…` | The `redirect` value breaks the rule above | Send a path, or leave it out |
| `429 too_many_requests` | `Too many requests to this operation. Wait the number of seconds in Retry-After, then try again.` | More than 100 links in 60 seconds across your account | Wait `Retry-After` seconds; the helper does this for you |
| `500 internal_error` | `Could not issue a sign-in token, please retry.` | A rare fault while issuing the link | Retry once |

Both `403` answers carry the code `forbidden`, so the routes above do not read the message to tell
them apart. They read the student instead. If the student's list of organizations lacks this one,
access is the problem. Otherwise it is your account's permission. See [Errors](https://hub.main-team.org/api/errors#forbidden).

### Granting access to an organization

`activatedPlatformsThisSeason` lists the organizations a student may use: `common` means every
organization, and otherwise it holds organization slugs. A student you register without the field gets
`["common"]`, so you see this refusal only if you set the list yourself.

The simplest fix adds one organization and touches nothing else: send the organization route an
empty body. Here the student was on `["neo"]`, and `<organizationId>` is stem's:

```bash [curl]
curl -s -X PUT https://api.main-team.org/v1/<organizationId>/student/66e6a3f5c1d2b30012a4f9c1 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json [Response 200 (abridged)]
{
  "success": true,
  "message": "Student updated successfully.",
  "data": {
    "_id": "66e6a3f5c1d2b30012a4f9c1",
    "username": "XXB1045",
    "firstName": "Jane",
    "lastName": "Doe",
    "activatedPlatformsThisSeason": ["neo", "stem"]
  }
}
```

Without `activatedPlatformsThisSeason` in the body, this route puts the organization in the path into
the list, keeps the entries already there, and leaves a list that holds `common` as it is. Profile
fields you include are updated too. It needs `student/update` on that organization.

To add several organizations at once, send them in `activatedPlatformsThisSeason`, on either route.
They are added to the student's list, and nothing already on it is removed:

```bash [curl]
curl -s -X PUT https://api.main-team.org/v1/student/66e6a3f5c1d2b30012a4f9c1 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "activatedPlatformsThisSeason": ["stem", "gmath"] }'
```

This needs `student/update` on `mto`. Either route accepts `common` and the slugs `stem`,
`hilingua`, `neo`, `gmath` and `coding`; any other value, `mto` included, is a `400`. No update
removes an organization from the list. See [Students](https://hub.main-team.org/api/guides/students).

## What the student sees

1. **The panel, signed in.** The browser passes briefly through the Main Team sign-in address, then
   lands in the organization's panel as the student, on the page `redirect` named.
2. **On the first visit to an organization,** that organization creates its own copy of the student.
   From then on your applications, supervisor links, certificates and reports on that organization
   work for this student.
3. **If their email address is not confirmed,** the student still gets in, but the panel asks them to
   confirm the address with a 6-digit code, which it sends from `no-reply@main-team.org`. The prompt
   appears on every page until the address is confirmed, My Exams included, so they must confirm
   before they can start an exam. They can't put it off; the only way out is to sign out. A student
   already inside an exam room is not interrupted. A code is valid for 15 minutes, and they can ask
   for a new one after 60 seconds. Tell your students to check their spam folder for the code, and to
   confirm well before an exam day, for example right after their first sign-in.
4. **If the link was already used or has expired,** the browser shows an error saying the access token
   is invalid or expired. Nothing is wrong with the account. Pressing your button again mints a fresh
   link.

Changing a student's email address through the API withdraws its confirmation, because nobody has yet
proved they receive mail at the new address. The next time the student uses the panel, it asks them to
confirm the new address and keeps asking until they do. Sending the address they already have changes
nothing.

## Test it

- [ ] Press the button: you land in the organization's panel, signed in.
- [ ] In the browser's network tab, copy the `Location` of your `303` and open it in a private window.
  You get the invalid-or-expired error: the link was spent by the first visit.
- [ ] Mint a link with curl, wait more than two minutes, then open it. Same error: it expired.
- [ ] Register a test student with `activatedPlatformsThisSeason: ["neo"]` and press the stem
  button: your page shows the "not entered" message, not a raw error. An update can't take an
  organization away, so use a new student for this.
- [ ] Search your logs for `accessToken=`. You should find nothing.

## Next steps

- [Sign-in links](https://hub.main-team.org/api/guides/sign-in-links): every rule for the link, in one place.
- [Change an application](https://hub.main-team.org/api/tutorials/change-an-application): what to do when the student wants a
  different language or date.
- [Security](https://hub.main-team.org/api/security): handling links, tokens and students' data.
- Reference: [createSigninLink](https://hub.main-team.org/api/reference/create-signin-link),
  [updateStudent](https://hub.main-team.org/api/reference/update-student), [getStudent](https://hub.main-team.org/api/reference/get-student).
