1 error span on 0 routes in the last 7 days.
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from 'react';
import { useSession } from '@/modules/auth/utils/auth-client';
/**
* Org-admin cross-user administration.
*
* An org owner/admin can point the playbook and settings surfaces at a
* teammate's configuration instead of their own. The selection lives here, in
* one provider, and every scoped tRPC call reads it through `useTargetUserId`
* or `useScopedInput` rather than taking it as a prop.
*
* The default is always `undefined`, which the server reads as "the signed-in
* user". A non-admin never sets it, so nothing about their experience changes.
*/
/** The teammate currently being administered. `null` means "myself". */
export interface AdministeredUser {
id: string;
name: string | null;
email: string | null;
}
interface AdministeredUserContextValue {
/**
* The `targetUserId` scoped tRPC calls carry. `undefined` means the
* signed-in user.
*/
targetUserId: string | undefined;
/** The selected teammate, or `null` when administering yourself. */
targetUser: AdministeredUser | null;
/** True only while a teammate other than the signed-in user is selected. */
isAdministeringOther: boolean;
/** Select a teammate, or pass `null` to go back to administering yourself. */
setTargetUser: (user: AdministeredUser | null) => void;
}
const SELF: AdministeredUserContextValue = {
targetUserId: undefined,
targetUser: null,
isAdministeringOther: false,
setTargetUser: () => {},
};
/**
* The default value is the "administering myself" value, not `null`. A tree
* rendered without the provider (tests, isolated stories) then behaves exactly
* like a signed-in non-admin instead of throwing.
*/
const AdministeredUserContext = createContext<AdministeredUserContextValue>(SELF);
const STORAGE_KEY=[redacted];
/**
* The selection is stamped with the signed-in user who made it. A tab that
* outlives a sign-out would otherwise hand the next person a live selection
* they never made, and one their account may have no standing for.
*/
interface StoredSelection {
ownerUserId: string;
user: AdministeredUser;
}
function readStored(): StoredSelection | null {
if (typeof window === 'undefined') return null;
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as StoredSelection;
return typeof parsed?.ownerUserId === 'string' && typeof parsed?.user?.id === 'string'
? parsed
: null;
} catch {
return null;
}
}
function writeStored(selection: StoredSelection | null) {
if (typeof window === 'undefined') return;
try {
if (selection) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(selection));
else sessionStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore sessionStorage errors (private mode, quota).
}
}
/**
* Holds the administered teammate for the whole app. Mounted once at the root
* so the selection survives navigation between the playbook surfaces.
*
* The selection is kept in sessionStorage, not localStorage: administering a
* teammate is a deliberate act, so it should not silently outlive the tab.
*/
export function AdministeredUserProvider({ children }: PropsWithChildren) {
const { data: session, isPending: isSessionPending } = useSession();
const sessionUserId = session?.user?.id;
const [stored, setStored] = useState<StoredSelection | null>(readStored);
const setTargetUser = useCallback(
(user: AdministeredUser | null) => {
// Selecting yourself is the same as clearing the selection.
const next =
user && sessionUserId && user.id !== sessionUserId
? { ownerUserId: sessionUserId, user }
: null;
writeStored(next);
setStored(next);
},
[sessionUserId],
);
// Drop a selection that belongs to a different sign-in. Waits for the session
// to settle: on a reload `session` is briefly undefined, and clearing then
// would throw away a selection that is still valid.
useEffect(() => {
if (isSessionPending || !stored) return;
if (stored.ownerUserId !== sessionUserId) {
writeStored(null);
setStored(null);
}
}, [stored, sessionUserId, isSessionPending]);
// Until the session settles the selection is treated as "not mine yet", so no
// request goes out carrying a target we have not confirmed belongs to us.
const targetUser =
!isSessionPending && stored && stored.ownerUserId === sessionUserId ? stored.user : null;
const value = useMemo<AdministeredUserContextValue>(
() => ({
targetUserId: targetUser?.id,
targetUser,
isAdministeringOther: !!targetUser,
setTargetUser,
}),
[targetUser, setTargetUser],
);
return (
<AdministeredUserContext.Provider value={value}>{children}</AdministeredUserContext.Provider>
);
}
/** Full administered-user state. Use `useTargetUserId` for plain call scoping. */
export function useAdministeredUser(): AdministeredUserContextValue {
return useContext(AdministeredUserContext);
}
/**
* The `targetUserId` to send with a scoped tRPC call, or `undefined` when the
* signed-in user is administering themselves.
*/
export function useTargetUserId(): string | undefined {
return useContext(AdministeredUserContext).targetUserId;
}
/**
* Adds the administered `targetUserId` to a tRPC input (or query key), leaving
* the input untouched when administering yourself.
*
* TYPESCRIPT DOES NOT CHECK THIS. The value returned here is not a fresh object
* literal, and TypeScript only rejects unknown properties on fresh literals, so
* `scoped({ name: 'x' })` passed to a route with no `targetUserId` compiles
* clean and the field is then silently dropped by zod, leaving the route to
* answer with the SIGNED-IN user's data under a banner naming a teammate. That
* is the exact defect `aop.listAopsForUser` shipped with.
*
* The guard is `modules/administeredUser/contract.ts`, which asserts every
* scoped route really accepts the field, plus the coverage test beside it that
* fails if a `scoped(...)` call site names a route the contract file does not.
* Adding a scoped call site means adding its route there. Nothing else catches
* an omission.
*
* `undefined` is never spread in, so for a non-admin the object is byte-identical
* to what it was before and TanStack Query's key hash is unchanged. For an admin
* the id becomes part of the input, so each teammate's data gets its own cache
* entry instead of overwriting yours.
*
* ONLY FOR SURFACES THAT MOUNT `AdministeredUserBar`. This is an ambient read of
* the picker, so it is right exactly where the picker is on screen saying so. A
* component that can also be mounted by the `/brain` document editor must take
* the owner as a prop instead: that editor opens any member's document and shows
* no picker, so an ambient read there resolves someone's OWN document against
* whichever teammate happened to be selected, matches nothing, and empties a
* screen that was working.
*/
export function useScopedInput(): <T extends object>(input: T) => T & { targetUserId?: string } {
const targetUserId = useTargetUserId();
return useCallback(
<T extends object>(input: T): T & { targetUserId?: string } =>
targetUserId ? { ...input, targetUserId } : input,
[targetUserId],
);
}
/**
* The same scoping, for a route whose WHOLE input is optional, such as
* `settings.get`.
*
* Administering yourself returns `undefined` rather than `{}`, which matters:
* tRPC only puts `input` in the query key when it is defined, so `undefined`
* produces the exact key a no-argument `settings.get.queryKey()` produces. The
* settings pages therefore keep sharing one cache entry with `useSettings`, and
* every existing bare-key reader and optimistic writer stays correct.
*
* Covered by `contract.ts` on the same terms as `useScopedInput`.
*/
export function useScopedOptionalInput(): { targetUserId: string } | undefined {
const targetUserId = useTargetUserId();
return useMemo(() => (targetUserId ? { targetUserId } : undefined), [targetUserId]);
}