# Activate a device

import { useState, useEffect } from 'react';

# Activate a device

An agent or command-line tool asked for access to **your** APIs.io account. It showed you a code.
Enter it below, check what it is asking for, and approve it.

Nothing long-lived is handed to the tool. It receives a scoped token that expires — you are never
asked to copy an API key into a chat window, which is the thing this flow exists to replace.

export const ACTIVATE_BOX = {
  border: '1px solid var(--border, #d0d7de)', borderRadius: '10px',
  padding: '1rem 1.15rem', margin: '1rem 0', background: 'var(--background, transparent)',
};

export const Activate = () => {
  // undefined = still loading, null = signed out, object = signed in
  const [me, setMe] = useState(undefined);
  const [code, setCode] = useState('');
  const [pending, setPending] = useState(null);
  const [state, setState] = useState('');       // '', 'checking', 'approved', 'denied'
  const [error, setError] = useState('');

  useEffect(() => {
    fetch('/api/v1/auth/me', { credentials: 'include' })
      .then((r) => (r.ok ? r.json() : null)).then(setMe).catch(() => setMe(null));
    // verification_uri_complete puts the code in the URL, so the human types nothing at all.
    const q = new URLSearchParams(window.location.search).get('user_code');
    if (q) setCode(q);
  }, []);

  // Look the code up rather than approving blind. A human approving a credential has to be told
  // WHICH tool is asking and WHAT it gets — otherwise "approve" is a button with no meaning.
  const check = async (value) => {
    const c = (value ?? code).trim();
    if (!c) return;
    setState('checking'); setError(''); setPending(null);
    try {
      const r = await fetch(`/api/v1/auth/device/pending?user_code=${encodeURIComponent(c)}`, { credentials: 'include' });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) {
        setError(j.error === 'expired_token'
          ? 'That code has expired or was never issued. Ask the tool to start again — codes last 15 minutes.'
          : (j.error_description || 'Could not look that code up.'));
        setState('');
        return;
      }
      setPending(j); setState('');
    } catch { setError('Could not reach APIs.io.'); setState(''); }
  };

  const decide = async (approve) => {
    setState('checking'); setError('');
    try {
      const r = await fetch('/api/v1/auth/device/approve', {
        method: 'POST', credentials: 'include',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({ user_code: code.trim(), approve: approve ? 'yes' : 'no' }).toString(),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) { setError(j.error_description || 'That did not work.'); setState(''); return; }
      setState(j.status === 'approved' ? 'approved' : 'denied');
    } catch { setError('Could not reach APIs.io.'); setState(''); }
  };

  if (me === undefined) return <p>Loading…</p>;

  if (me === null) return (
    <div style={ACTIVATE_BOX}>
      <p><strong>Sign in first.</strong> Approving a device grants it access to your account at
      your plan, so we have to know who you are before you can approve anything.</p>
      <p style={{ fontSize: '0.92rem', opacity: 0.8 }}>
        Your code is safe to leave in this page while you sign in — come back to this tab afterwards.
      </p>
      <p><a href="/account">Sign in on the account page →</a></p>
    </div>
  );

  if (state === 'approved') return (
    <div style={ACTIVATE_BOX}>
      <p><strong>Approved.</strong> You can close this tab — the tool receives its token within a
      few seconds.</p>
      <p style={{ fontSize: '0.92rem', opacity: 0.8 }}>
        It was granted <code>{pending?.scope_granted || 'apis:read'}</code> at your plan
        ({pending?.tier || 'free'}). To revoke it later, revoke the client from your{' '}
        <a href="/account">account page</a>.
      </p>
    </div>
  );

  if (state === 'denied') return (
    <div style={ACTIVATE_BOX}>
      <p><strong>Denied.</strong> Nothing was granted and the code is now dead. If you did not
      start this, you do not need to do anything else.</p>
    </div>
  );

  return (
    <div style={ACTIVATE_BOX}>
      <p>Signed in as <strong>{me.login || me.email || 'you'}</strong> ({me.tier || 'free'}).</p>

      <label style={{ display: 'block', fontWeight: 600, marginBottom: '0.35rem' }}>
        Code from the tool
      </label>
      <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
        <input
          value={code}
          onChange={(e) => { setCode(e.target.value); setPending(null); }}
          placeholder="WDJB-MJHT"
          spellCheck={false}
          style={{
            fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
            fontSize: '1.15rem', letterSpacing: '0.08em', padding: '0.5rem 0.7rem',
            borderRadius: '8px', border: '1px solid var(--border, #d0d7de)', minWidth: '12rem',
          }}
        />
        <button onClick={() => check()} disabled={state === 'checking' || !code.trim()}
          style={{ padding: '0.5rem 1rem', borderRadius: '8px', fontWeight: 600,
                   border: '1px solid var(--border, #d0d7de)', cursor: 'pointer' }}>
          {state === 'checking' ? 'Checking…' : 'Check code'}
        </button>
      </div>
      <p style={{ fontSize: '0.86rem', opacity: 0.75, marginTop: '0.4rem' }}>
        Upper or lower case, with or without the dash — it does not matter.
      </p>

      {error && <p style={{ color: 'var(--danger, #cf222e)' }}>{error}</p>}

      {pending && (
        <div style={{ ...ACTIVATE_BOX, marginTop: '1rem' }}>
          <p style={{ marginTop: 0 }}>
            <strong>{pending.client_name}</strong> is asking for access to your account.
          </p>
          <ul style={{ margin: '0.5rem 0 0.9rem' }}>
            <li>Scope it would get: <code>{pending.scope_granted || '(none)'}</code></li>
            {pending.scope_requested !== pending.scope_granted && (
              <li style={{ opacity: 0.8 }}>
                It asked for <code>{pending.scope_requested}</code>; your plan
                ({pending.tier}) grants the narrower set above.
              </li>
            )}
            <li>Audience: <code>{pending.resource}</code></li>
          </ul>
          <p style={{ fontSize: '0.9rem', opacity: 0.85 }}>
            Only approve this if you just started it yourself. If a code arrived any other way —
            an email, a message, someone reading it to you — decline.
          </p>
          <div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
            <button onClick={() => decide(true)} disabled={state === 'checking'}
              style={{ padding: '0.55rem 1.1rem', borderRadius: '8px', fontWeight: 600,
                       border: '1px solid var(--border, #d0d7de)', cursor: 'pointer' }}>
              Approve
            </button>
            <button onClick={() => decide(false)} disabled={state === 'checking'}
              style={{ padding: '0.55rem 1.1rem', borderRadius: '8px',
                       border: '1px solid var(--border, #d0d7de)', cursor: 'pointer' }}>
              Decline
            </button>
          </div>
        </div>
      )}
    </div>
  );
};

<Activate />

## For the tool builder

This is [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628), the OAuth device authorization
grant. Register a client once, then:

```
POST /api/v1/auth/device
     client_id=<your client_id>&scope=apis:read

→ { "device_code": "…", "user_code": "WDJB-MJHT",
    "verification_uri": "https://apis.io/developer/activate",
    "verification_uri_complete": "https://apis.io/developer/activate?user_code=WDJB-MJHT",
    "expires_in": 900, "interval": 5 }
```

Show your human `verification_uri_complete`, then poll:

```
POST /api/v1/auth/token
     grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=…&client_id=…

400 authorization_pending   keep polling, no faster than `interval`
400 slow_down               add 5 seconds and keep polling
400 access_denied           they said no — stop
400 expired_token           the 15 minutes ran out — start again
200 { "access_token": … }
```

The token carries the tier of the human who approved it, not your client's. Everything is
advertised in [the AS metadata](https://apis.io/.well-known/oauth-authorization-server), so a
standard OAuth library can discover this flow without reading this page.
