File size: 3,360 Bytes
f568a4f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**

 * useIdentity — which plane is this session on?

 *

 * The backend enforces a hard boundary: an operator sees the whole instance, a signed-in end user

 * sees only their own account plane (their keys, their reasoning, their billing) plus the read-only

 * showcase views. See `docs/architecture/MULTI_TENANT_AUTH.md`.

 *

 * Without this hook the console renders the operator's navigation to everyone and a signed-in user

 * discovers the boundary by collecting 403s. `GET /api/me` is the one identity call a tenant may

 * make — it reports the caller's own identity and deliberately nothing about the instance.

 *

 * Degrades quietly: if the endpoint is unreachable (older backend, gateway down) the hook reports

 * `unknown` and callers should fall back to showing everything, exactly as before this existed.

 */

import { useEffect, useState } from "react";
import { ApiError, api } from "@/services/apiClient";

export type IdentityKind = "open" | "admin" | "tenant" | "unknown";

export interface Identity {
  kind: IdentityKind;
  isAdmin: boolean;
  /** A short hash of the account id — never the raw JWT subject. */
  tenantLabel: string | null;
  plane: "instance" | "account" | "unknown";
  tenancyEnabled: boolean;
  authRequired: boolean;
  /** Route patterns this caller may reach; null for an operator (everything). */
  allowedRoutes: string[] | null;
}

interface MeResponse {
  kind?: string;
  is_admin?: boolean;
  tenant_label?: string | null;
  plane?: string;
  tenancy_enabled?: boolean;
  auth_required?: boolean;
  allowed_routes?: string[] | null;
}

const UNKNOWN: Identity = {
  kind: "unknown",
  // Permissive when we genuinely do not know: the backend is the enforcer, so a wrong guess here
  // hides features from an operator rather than exposing anything to a tenant.
  isAdmin: true,
  tenantLabel: null,
  plane: "unknown",
  tenancyEnabled: false,
  authRequired: false,
  allowedRoutes: null,
};

export function useIdentity(): { identity: Identity; loading: boolean; offline: boolean } {
  const [identity, setIdentity] = useState<Identity>(UNKNOWN);
  const [loading, setLoading] = useState(true);
  const [offline, setOffline] = useState(false);

  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const me = await api.get<MeResponse>("/api/me");
        if (cancelled) return;
        const kind = (["open", "admin", "tenant"].includes(String(me.kind))
          ? (me.kind as IdentityKind)
          : "unknown");
        setIdentity({
          kind,
          isAdmin: me.is_admin !== false,
          tenantLabel: me.tenant_label ?? null,
          plane: me.plane === "account" ? "account" : me.plane === "instance" ? "instance" : "unknown",
          tenancyEnabled: Boolean(me.tenancy_enabled),
          authRequired: Boolean(me.auth_required),
          allowedRoutes: Array.isArray(me.allowed_routes) ? me.allowed_routes : null,
        });
      } catch (err) {
        if (cancelled) return;
        setOffline(err instanceof ApiError ? err.offline : true);
        setIdentity(UNKNOWN);
      } finally {
        if (!cancelled) setLoading(false);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  return { identity, loading, offline };
}