@karni/impersonation-ui 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,16 +21,16 @@ start.mutate({ user: "customer@example.com", reason: "ticket #7" });
21
21
  ```
22
22
 
23
23
  - `<ImpersonationBanner>` reads `GET /api/impersonation-sessions/current/`
24
- and renders nothing outside a session; inside one it names both
25
- identities, the minutes left, and **Stop impersonating**, which posts
26
- `…/stop/`, adopts the impersonator's tokens (JWT mode — through
24
+ and renders nothing outside a session. Inside one it names both
25
+ identities, the minutes left, and **Stop impersonating**. That button posts
26
+ `…/stop/`, adopts the impersonator's tokens (JWT mode, through
27
27
  `useAuth().adoptTokens`) or re-syncs `/me/` (session mode), and drops every
28
28
  cached resource query so the app re-reads as the right person.
29
29
  - `useStartImpersonation()` does the same in the other direction after
30
30
  `POST …/start/` (the backend enforces the permission, the no-escalation
31
- rule and the reason; its refusal is the mutation's error).
31
+ rule and the reason, and its refusal is the mutation's error).
32
32
  - `useImpersonation()` is the query behind the banner.
33
33
 
34
34
  Peer dependencies: `@karni/ui ^0.1`, React 18/19, `@tanstack/react-query` 5.
35
- Tokens only; registers the `impersonation-banner` role on `@karni/ui`'s role
35
+ It uses tokens only, and registers the `impersonation-banner` role on `@karni/ui`'s role
36
36
  registry when it is present.
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const s=require("@karni/ui"),a=require("react/jsx-runtime"),d=require("react"),c=require("@tanstack/react-query");function x(e){const n=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(e){for(const t in e)if(t!=="default"){const r=Object.getOwnPropertyDescriptor(e,t);Object.defineProperty(n,t,r.get?r:{enumerable:!0,get:()=>e[t]})}}return n.default=e,Object.freeze(n)}const j=x(s),p=[{role:"impersonation-banner",component:"ImpersonationBanner",package:"@karni/impersonation-ui",description:"The persistent bar naming both identities during an impersonated session, with Stop."}];function y(e=j){if(typeof e!="object"||e===null)return!1;const n=e.registerComponentRole;if(typeof n!="function")return!1;for(const t of p)n(t.role,t);return!0}const u="impersonation-sessions";function l(){return[...s.karniKeys.resource(u),"current"]}function g(e){const n=s.useKarniClient(),t=(e==null?void 0:e.poll)??!1;return c.useQuery({queryKey:l(),queryFn:({signal:r})=>n.request(`/${u}/current/`,{signal:r}),refetchInterval:t===!1?!1:t})}function b(){const e=s.useAuth(),n=c.useQueryClient();return async t=>{await e.adoptTokens(t.access??null,t.refresh??null),n.removeQueries({queryKey:["karni"],predicate:r=>r.queryKey[1]!=="$schema"}),await n.invalidateQueries({queryKey:l()})}}function v(){const e=s.useKarniClient(),n=b();return c.useMutation({mutationFn:({user:t,reason:r})=>e.request(`/${u}/start/`,{method:"POST",body:{user:t,reason:r??""}}),onSuccess:t=>n(t)})}function O(){const e=s.useKarniClient(),n=b();return c.useMutation({mutationFn:()=>e.request(`/${u}/stop/`,{method:"POST",body:{}}),onSuccess:t=>n(t)})}function N(e,n){const t=new Date(e).getTime();return Number.isNaN(t)?0:Math.max(Math.floor((t-n)/6e4),0)}function q(e){if(e instanceof Error){const n=e.data;if(typeof n=="object"&&n!==null&&"detail"in n){const t=n.detail;if(typeof t=="string")return t}return e.message}return"Could not stop impersonating."}function C({poll:e=6e4,onStopped:n,className:t}){var m;const r=g({poll:e}),o=O(),[h,S]=d.useState(()=>Date.now());d.useEffect(()=>{const I=setInterval(()=>S(Date.now()),3e4);return()=>clearInterval(I)},[]);const i=((m=r.data)==null?void 0:m.session)??null;if(i===null||!i.active)return null;const f=N(i.expires_at,h);return a.jsxs("div",{role:"status","aria-live":"polite","data-testid":"impersonation-banner",className:s.cn("fixed inset-x-0 bottom-0 z-50 flex flex-wrap items-center justify-center gap-3 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground shadow-lg",t),children:[a.jsxs("span",{children:["You are acting as ",a.jsx("strong",{children:i.target.email??i.target.id})," — signed in as ",i.actor.email??i.actor.id,"."," ",a.jsxs("span",{className:"opacity-80",children:[f," ",f===1?"minute":"minutes"," left"]})]}),a.jsx(s.Button,{size:"sm",variant:"secondary",disabled:o.isPending,onClick:()=>o.mutate(void 0,{onSuccess:()=>n==null?void 0:n()}),children:o.isPending?"Stopping…":"Stop impersonating"}),o.error!==null&&o.error!==void 0?a.jsx("span",{role:"alert",children:q(o.error)}):null]})}y();exports.IMPERSONATION_COMPONENT_ROLES=p;exports.ImpersonationBanner=C;exports.RESOURCE=u;exports.impersonationKey=l;exports.registerImpersonationRoles=y;exports.useImpersonation=g;exports.useStartImpersonation=v;exports.useStopImpersonation=O;
2
- //# sourceMappingURL=index.cjs.map
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,s)=>(s=n==null?{}:e(i(n)),o(r||!n||!n.__esModule||!a.call(n,`default`)?t(s,`default`,{value:n,enumerable:!0}):s,n));let c=require("@karni/ui");c=s(c,1);let l=require("react"),u=require("@tanstack/react-query"),d=require("react/jsx-runtime");var f=[{role:`impersonation-banner`,component:`ImpersonationBanner`,package:`@karni/impersonation-ui`,description:`The persistent bar naming both identities during an impersonated session, with Stop.`}];function p(e=c){if(typeof e!=`object`||!e)return!1;let t=e.registerComponentRole;if(typeof t!=`function`)return!1;for(let e of f)t(e.role,e);return!0}var m=`impersonation-sessions`;function h(){return[...c.karniKeys.resource(m),`current`]}function g(e){let t=(0,c.useKarniClient)(),n=e?.poll??!1;return(0,u.useQuery)({queryKey:h(),queryFn:({signal:e})=>t.request(`/${m}/current/`,{signal:e}),refetchInterval:n!==!1&&n})}function _(){let e=(0,c.useAuth)(),t=(0,u.useQueryClient)();return async n=>{await e.adoptTokens(n.access??null,n.refresh??null),t.removeQueries({queryKey:[`karni`],predicate:e=>e.queryKey[1]!==`$schema`}),await t.invalidateQueries({queryKey:h()})}}function v(){let e=(0,c.useKarniClient)(),t=_();return(0,u.useMutation)({mutationFn:({user:t,reason:n})=>e.request(`/${m}/start/`,{method:`POST`,body:{user:t,reason:n??``}}),onSuccess:e=>t(e)})}function y(){let e=(0,c.useKarniClient)(),t=_();return(0,u.useMutation)({mutationFn:()=>e.request(`/${m}/stop/`,{method:`POST`,body:{}}),onSuccess:e=>t(e)})}function b(e,t){let n=new Date(e).getTime();return Number.isNaN(n)?0:Math.max(Math.floor((n-t)/6e4),0)}function x(e){if(e instanceof Error){let t=e.data;if(typeof t==`object`&&t&&`detail`in t){let e=t.detail;if(typeof e==`string`)return e}return e.message}return`Could not stop impersonating.`}function S({poll:e=6e4,onStopped:t,className:n}){let r=g({poll:e}),i=y(),[a,o]=(0,l.useState)(()=>Date.now());(0,l.useEffect)(()=>{let e=setInterval(()=>o(Date.now()),3e4);return()=>clearInterval(e)},[]);let s=r.data?.session??null;if(s===null||!s.active)return null;let u=b(s.expires_at,a);return(0,d.jsxs)(`div`,{role:`status`,"aria-live":`polite`,"data-testid":`impersonation-banner`,className:(0,c.cn)(`fixed inset-x-0 bottom-0 z-50 flex flex-wrap items-center justify-center gap-3 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground shadow-lg`,n),children:[(0,d.jsxs)(`span`,{children:[`You are acting as `,(0,d.jsx)(`strong`,{children:s.target.email??s.target.id}),`, signed in as`,` `,s.actor.email??s.actor.id,`.`,` `,(0,d.jsxs)(`span`,{className:`opacity-80`,children:[u,` `,u===1?`minute`:`minutes`,` left`]})]}),(0,d.jsx)(c.Button,{size:`sm`,variant:`secondary`,disabled:i.isPending,onClick:()=>i.mutate(void 0,{onSuccess:()=>t?.()}),children:i.isPending?`Stopping…`:`Stop impersonating`}),i.error!==null&&i.error!==void 0?(0,d.jsx)(`span`,{role:`alert`,children:x(i.error)}):null]})}p(),exports.IMPERSONATION_COMPONENT_ROLES=f,exports.ImpersonationBanner=S,exports.RESOURCE=m,exports.impersonationKey=h,exports.registerImpersonationRoles=p,exports.useImpersonation=g,exports.useStartImpersonation=v,exports.useStopImpersonation=y;
2
+ //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","sources":["../src/roles.ts","../src/hooks.ts","../src/ImpersonationBanner.tsx","../src/index.ts"],"sourcesContent":["/** Component-role registration, feature-detected on @karni/ui's registry. */\nimport * as karniUi from \"@karni/ui\";\n\nexport interface ComponentRoleDefinition {\n role: string;\n component: string;\n package: string;\n description: string;\n}\n\nexport const IMPERSONATION_COMPONENT_ROLES: readonly ComponentRoleDefinition[] = [\n {\n role: \"impersonation-banner\",\n component: \"ImpersonationBanner\",\n package: \"@karni/impersonation-ui\",\n description:\n \"The persistent bar naming both identities during an impersonated session, with Stop.\",\n },\n];\n\ntype RegisterComponentRole = (role: string, definition: ComponentRoleDefinition) => void;\n\nexport function registerImpersonationRoles(registry: unknown = karniUi): boolean {\n if (typeof registry !== \"object\" || registry === null) return false;\n const candidate = (registry as Record<string, unknown>)[\"registerComponentRole\"];\n if (typeof candidate !== \"function\") return false;\n for (const definition of IMPERSONATION_COMPONENT_ROLES) {\n (candidate as RegisterComponentRole)(definition.role, definition);\n }\n return true;\n}\n","/**\n * Hooks over the impersonation API, on @karni/ui's client, auth and query keys.\n *\n * - `useImpersonation()` — `GET /impersonation-sessions/current/`: the session\n * this user is in, or null;\n * - `useStartImpersonation()` — `POST …/start/`; in JWT mode the tokens the\n * backend returns are adopted through `useAuth().adoptTokens`, in session\n * mode `/me/` is re-synced; every resource query is invalidated either way;\n * - `useStopImpersonation()` — `POST …/stop/`, the mirror image.\n */\nimport { karniKeys, useAuth, useKarniClient } from \"@karni/ui\";\nimport {\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationResult,\n type UseQueryResult,\n} from \"@tanstack/react-query\";\nimport type { CurrentResponse, SessionResponse } from \"./types\";\n\nexport const RESOURCE = \"impersonation-sessions\";\n\nexport function impersonationKey(): readonly unknown[] {\n return [...karniKeys.resource(RESOURCE), \"current\"] as const;\n}\n\nexport interface UseImpersonationOptions {\n /** `false` (default) = fetch once; a number = refetch every N milliseconds (watch the clock). */\n poll?: false | number;\n}\n\nexport function useImpersonation(\n options?: UseImpersonationOptions,\n): UseQueryResult<CurrentResponse> {\n const client = useKarniClient();\n const poll = options?.poll ?? false;\n return useQuery<CurrentResponse>({\n queryKey: impersonationKey(),\n queryFn: ({ signal }) => client.request<CurrentResponse>(`/${RESOURCE}/current/`, { signal }),\n refetchInterval: poll === false ? false : poll,\n });\n}\n\nexport interface StartInput {\n /** The user to act as: id or email. */\n user: string;\n reason?: string;\n}\n\nfunction useAdoption(): (response: SessionResponse) => Promise<void> {\n const auth = useAuth();\n const queryClient = useQueryClient();\n return async (response) => {\n await auth.adoptTokens(response.access ?? null, response.refresh ?? null);\n queryClient.removeQueries({\n queryKey: [\"karni\"],\n predicate: (query) => query.queryKey[1] !== \"$schema\",\n });\n await queryClient.invalidateQueries({ queryKey: impersonationKey() });\n };\n}\n\nexport function useStartImpersonation(): UseMutationResult<SessionResponse, unknown, StartInput> {\n const client = useKarniClient();\n const adopt = useAdoption();\n return useMutation<SessionResponse, unknown, StartInput>({\n mutationFn: ({ user, reason }) =>\n client.request<SessionResponse>(`/${RESOURCE}/start/`, {\n method: \"POST\",\n body: { user, reason: reason ?? \"\" },\n }),\n onSuccess: (response) => adopt(response),\n });\n}\n\nexport function useStopImpersonation(): UseMutationResult<SessionResponse, unknown, undefined> {\n const client = useKarniClient();\n const adopt = useAdoption();\n return useMutation<SessionResponse, unknown, undefined>({\n mutationFn: () =>\n client.request<SessionResponse>(`/${RESOURCE}/stop/`, { method: \"POST\", body: {} }),\n onSuccess: (response) => adopt(response),\n });\n}\n","/**\n * <ImpersonationBanner> — the persistent bar naming both identities, with\n * a one-click exit, for a SPA. Renders nothing outside an impersonated\n * session. Mount it once near the root (inside <AuthProvider>).\n *\n * Reads `GET /impersonation-sessions/current/`; Stop posts `…/stop/` and\n * adopts the impersonator's tokens (JWT) or re-syncs `/me/` (session mode).\n */\nimport { Button, cn } from \"@karni/ui\";\nimport { useEffect, useState, type ReactElement } from \"react\";\nimport { useImpersonation, useStopImpersonation } from \"./hooks\";\n\nexport interface ImpersonationBannerProps {\n /** Refetch interval in ms so the minutes left stay honest; default 60 000. */\n poll?: false | number;\n /** Called after a successful stop (e.g. navigate home). */\n onStopped?: () => void;\n className?: string;\n}\n\nfunction minutesLeft(expiresAt: string, now: number): number {\n const end = new Date(expiresAt).getTime();\n if (Number.isNaN(end)) return 0;\n return Math.max(Math.floor((end - now) / 60_000), 0);\n}\n\nfunction errorText(error: unknown): string {\n if (error instanceof Error) {\n const data = (error as { data?: unknown }).data;\n if (typeof data === \"object\" && data !== null && \"detail\" in data) {\n const detail = (data as { detail?: unknown }).detail;\n if (typeof detail === \"string\") return detail;\n }\n return error.message;\n }\n return \"Could not stop impersonating.\";\n}\n\nexport function ImpersonationBanner({\n poll = 60_000,\n onStopped,\n className,\n}: ImpersonationBannerProps): ReactElement | null {\n const query = useImpersonation({ poll });\n const stop = useStopImpersonation();\n const [now, setNow] = useState(() => Date.now());\n useEffect(() => {\n const timer = setInterval(() => setNow(Date.now()), 30_000);\n return () => clearInterval(timer);\n }, []);\n const session = query.data?.session ?? null;\n if (session === null || !session.active) return null;\n const left = minutesLeft(session.expires_at, now);\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n data-testid=\"impersonation-banner\"\n className={cn(\n \"fixed inset-x-0 bottom-0 z-50 flex flex-wrap items-center justify-center gap-3 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground shadow-lg\",\n className,\n )}\n >\n <span>\n You are acting as <strong>{session.target.email ?? session.target.id}</strong> — signed in\n as {session.actor.email ?? session.actor.id}.{\" \"}\n <span className=\"opacity-80\">\n {left} {left === 1 ? \"minute\" : \"minutes\"} left\n </span>\n </span>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={stop.isPending}\n onClick={() => stop.mutate(undefined, { onSuccess: () => onStopped?.() })}\n >\n {stop.isPending ? \"Stopping…\" : \"Stop impersonating\"}\n </Button>\n {stop.error !== null && stop.error !== undefined ? (\n <span role=\"alert\">{errorText(stop.error)}</span>\n ) : null}\n </div>\n );\n}\n","/** @karni/impersonation-ui public barrel: explicit exports only. */\nimport { registerImpersonationRoles } from \"./roles\";\n\nexport { ImpersonationBanner } from \"./ImpersonationBanner\";\nexport type { ImpersonationBannerProps } from \"./ImpersonationBanner\";\n\nexport {\n RESOURCE,\n impersonationKey,\n useImpersonation,\n useStartImpersonation,\n useStopImpersonation,\n} from \"./hooks\";\nexport type { StartInput, UseImpersonationOptions } from \"./hooks\";\n\nexport type {\n CurrentResponse,\n EndReason,\n ImpersonationSession,\n Person,\n SessionResponse,\n} from \"./types\";\n\nexport { IMPERSONATION_COMPONENT_ROLES, registerImpersonationRoles } from \"./roles\";\nexport type { ComponentRoleDefinition } from \"./roles\";\n\nregisterImpersonationRoles();\n"],"names":["IMPERSONATION_COMPONENT_ROLES","registerImpersonationRoles","registry","karniUi","candidate","definition","RESOURCE","impersonationKey","karniKeys","useImpersonation","options","client","useKarniClient","poll","useQuery","signal","useAdoption","auth","useAuth","queryClient","useQueryClient","response","query","useStartImpersonation","adopt","useMutation","user","reason","useStopImpersonation","minutesLeft","expiresAt","now","end","errorText","error","data","detail","ImpersonationBanner","onStopped","className","stop","setNow","useState","useEffect","timer","session","_a","left","jsxs","cn","jsx","Button"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["karniUi","karniKeys","useKarniClient","useQuery","useAuth","useQueryClient","useMutation","useState","cn","Button","__create","__defProp","__getOwnPropDesc","__getOwnPropNames","__getProtoOf","__hasOwnProp","__copyProps","from","keys","i","n","key","to","except","k","desc","__toESM","mod","isNodeMode","target","_karni_ui","react","_tanstack_react_query","react_jsx_runtime","IMPERSONATION_COMPONENT_ROLES","registerImpersonationRoles","registry","candidate","\"registerComponentRole\"","definition","RESOURCE","impersonationKey","useImpersonation","client","poll","options","useAdoption","auth","queryClient","response","query","useStartImpersonation","adopt","reason","useStopImpersonation","minutesLeft","end","expiresAt","now","errorText","error","data","detail","ImpersonationBanner","stop","setNow","timer","session","left","className","onStopped"],"sources":["../src/roles.ts","../src/hooks.ts","../src/ImpersonationBanner.tsx","../src/index.ts"],"sourcesContent":["/** Component-role registration, feature-detected on @karni/ui's registry. */\nimport * as karniUi from \"@karni/ui\";\n\nexport interface ComponentRoleDefinition {\n role: string;\n component: string;\n package: string;\n description: string;\n}\n\nexport const IMPERSONATION_COMPONENT_ROLES: readonly ComponentRoleDefinition[] = [\n {\n role: \"impersonation-banner\",\n component: \"ImpersonationBanner\",\n package: \"@karni/impersonation-ui\",\n description:\n \"The persistent bar naming both identities during an impersonated session, with Stop.\",\n },\n];\n\ntype RegisterComponentRole = (role: string, definition: ComponentRoleDefinition) => void;\n\nexport function registerImpersonationRoles(registry: unknown = karniUi): boolean {\n if (typeof registry !== \"object\" || registry === null) return false;\n const candidate = (registry as Record<string, unknown>)[\"registerComponentRole\"];\n if (typeof candidate !== \"function\") return false;\n for (const definition of IMPERSONATION_COMPONENT_ROLES) {\n (candidate as RegisterComponentRole)(definition.role, definition);\n }\n return true;\n}\n","/**\n * Hooks over the impersonation API, on @karni/ui's client, auth and query keys.\n *\n * - `useImpersonation()`: `GET /impersonation-sessions/current/`, the session\n * this user is in, or null.\n * - `useStartImpersonation()`: `POST …/start/`. In JWT mode the tokens the\n * backend returns are adopted through `useAuth().adoptTokens`, and in session\n * mode `/me/` is re-synced. Every resource query is invalidated either way.\n * - `useStopImpersonation()`: `POST …/stop/`, the mirror image.\n */\nimport { karniKeys, useAuth, useKarniClient } from \"@karni/ui\";\nimport {\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationResult,\n type UseQueryResult,\n} from \"@tanstack/react-query\";\nimport type { CurrentResponse, SessionResponse } from \"./types\";\n\nexport const RESOURCE = \"impersonation-sessions\";\n\nexport function impersonationKey(): readonly unknown[] {\n return [...karniKeys.resource(RESOURCE), \"current\"] as const;\n}\n\nexport interface UseImpersonationOptions {\n /** `false` (default) = fetch once, a number = refetch every N milliseconds (watch the clock). */\n poll?: false | number;\n}\n\nexport function useImpersonation(\n options?: UseImpersonationOptions,\n): UseQueryResult<CurrentResponse> {\n const client = useKarniClient();\n const poll = options?.poll ?? false;\n return useQuery<CurrentResponse>({\n queryKey: impersonationKey(),\n queryFn: ({ signal }) => client.request<CurrentResponse>(`/${RESOURCE}/current/`, { signal }),\n refetchInterval: poll === false ? false : poll,\n });\n}\n\nexport interface StartInput {\n /** The user to act as: id or email. */\n user: string;\n reason?: string;\n}\n\nfunction useAdoption(): (response: SessionResponse) => Promise<void> {\n const auth = useAuth();\n const queryClient = useQueryClient();\n return async (response) => {\n await auth.adoptTokens(response.access ?? null, response.refresh ?? null);\n queryClient.removeQueries({\n queryKey: [\"karni\"],\n predicate: (query) => query.queryKey[1] !== \"$schema\",\n });\n await queryClient.invalidateQueries({ queryKey: impersonationKey() });\n };\n}\n\nexport function useStartImpersonation(): UseMutationResult<SessionResponse, unknown, StartInput> {\n const client = useKarniClient();\n const adopt = useAdoption();\n return useMutation<SessionResponse, unknown, StartInput>({\n mutationFn: ({ user, reason }) =>\n client.request<SessionResponse>(`/${RESOURCE}/start/`, {\n method: \"POST\",\n body: { user, reason: reason ?? \"\" },\n }),\n onSuccess: (response) => adopt(response),\n });\n}\n\nexport function useStopImpersonation(): UseMutationResult<SessionResponse, unknown, undefined> {\n const client = useKarniClient();\n const adopt = useAdoption();\n return useMutation<SessionResponse, unknown, undefined>({\n mutationFn: () =>\n client.request<SessionResponse>(`/${RESOURCE}/stop/`, { method: \"POST\", body: {} }),\n onSuccess: (response) => adopt(response),\n });\n}\n","/**\n * <ImpersonationBanner>: the persistent bar naming both identities, with\n * a one-click exit, for a SPA. Renders nothing outside an impersonated\n * session. Mount it once near the root (inside <AuthProvider>).\n *\n * Reads `GET /impersonation-sessions/current/`. Stop posts `…/stop/` and\n * adopts the impersonator's tokens (JWT) or re-syncs `/me/` (session mode).\n */\nimport { Button, cn } from \"@karni/ui\";\nimport { useEffect, useState, type ReactElement } from \"react\";\nimport { useImpersonation, useStopImpersonation } from \"./hooks\";\n\nexport interface ImpersonationBannerProps {\n /** Refetch interval in ms so the minutes left stay honest. Default 60 000. */\n poll?: false | number;\n /** Called after a successful stop (e.g. navigate home). */\n onStopped?: () => void;\n className?: string;\n}\n\nfunction minutesLeft(expiresAt: string, now: number): number {\n const end = new Date(expiresAt).getTime();\n if (Number.isNaN(end)) return 0;\n return Math.max(Math.floor((end - now) / 60_000), 0);\n}\n\nfunction errorText(error: unknown): string {\n if (error instanceof Error) {\n const data = (error as { data?: unknown }).data;\n if (typeof data === \"object\" && data !== null && \"detail\" in data) {\n const detail = (data as { detail?: unknown }).detail;\n if (typeof detail === \"string\") return detail;\n }\n return error.message;\n }\n return \"Could not stop impersonating.\";\n}\n\nexport function ImpersonationBanner({\n poll = 60_000,\n onStopped,\n className,\n}: ImpersonationBannerProps): ReactElement | null {\n const query = useImpersonation({ poll });\n const stop = useStopImpersonation();\n const [now, setNow] = useState(() => Date.now());\n useEffect(() => {\n const timer = setInterval(() => setNow(Date.now()), 30_000);\n return () => clearInterval(timer);\n }, []);\n const session = query.data?.session ?? null;\n if (session === null || !session.active) return null;\n const left = minutesLeft(session.expires_at, now);\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n data-testid=\"impersonation-banner\"\n className={cn(\n \"fixed inset-x-0 bottom-0 z-50 flex flex-wrap items-center justify-center gap-3 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground shadow-lg\",\n className,\n )}\n >\n <span>\n You are acting as <strong>{session.target.email ?? session.target.id}</strong>, signed in as{\" \"}\n {session.actor.email ?? session.actor.id}.{\" \"}\n <span className=\"opacity-80\">\n {left} {left === 1 ? \"minute\" : \"minutes\"} left\n </span>\n </span>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={stop.isPending}\n onClick={() => stop.mutate(undefined, { onSuccess: () => onStopped?.() })}\n >\n {stop.isPending ? \"Stopping…\" : \"Stop impersonating\"}\n </Button>\n {stop.error !== null && stop.error !== undefined ? (\n <span role=\"alert\">{errorText(stop.error)}</span>\n ) : null}\n </div>\n );\n}\n","/** @karni/impersonation-ui public barrel: explicit exports only. */\nimport { registerImpersonationRoles } from \"./roles\";\n\nexport { ImpersonationBanner } from \"./ImpersonationBanner\";\nexport type { ImpersonationBannerProps } from \"./ImpersonationBanner\";\n\nexport {\n RESOURCE,\n impersonationKey,\n useImpersonation,\n useStartImpersonation,\n useStopImpersonation,\n} from \"./hooks\";\nexport type { StartInput, UseImpersonationOptions } from \"./hooks\";\n\nexport type {\n CurrentResponse,\n EndReason,\n ImpersonationSession,\n Person,\n SessionResponse,\n} from \"./types\";\n\nexport { IMPERSONATION_COMPONENT_ROLES, registerImpersonationRoles } from \"./roles\";\nexport type { ComponentRoleDefinition } from \"./roles\";\n\nregisterImpersonationRoles();\n"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,140 +1,136 @@
1
- import * as v from "@karni/ui";
2
- import { karniKeys as x, useKarniClient as c, useAuth as b, Button as I, cn as q } from "@karni/ui";
3
- import { jsxs as a, jsx as u } from "react/jsx-runtime";
4
- import { useState as w, useEffect as N } from "react";
5
- import { useQuery as S, useMutation as f, useQueryClient as C } from "@tanstack/react-query";
6
- const O = [
7
- {
8
- role: "impersonation-banner",
9
- component: "ImpersonationBanner",
10
- package: "@karni/impersonation-ui",
11
- description: "The persistent bar naming both identities during an impersonated session, with Stop."
12
- }
13
- ];
14
- function T(n = v) {
15
- if (typeof n != "object" || n === null) return !1;
16
- const e = n.registerComponentRole;
17
- if (typeof e != "function") return !1;
18
- for (const t of O)
19
- e(t.role, t);
20
- return !0;
1
+ import * as e from "@karni/ui";
2
+ import { Button as t, cn as n, karniKeys as r, useAuth as i, useKarniClient as a } from "@karni/ui";
3
+ import { useEffect as o, useState as s } from "react";
4
+ import { useMutation as c, useQuery as l, useQueryClient as u } from "@tanstack/react-query";
5
+ import { jsx as d, jsxs as f } from "react/jsx-runtime";
6
+ //#region src/roles.ts
7
+ var p = [{
8
+ role: "impersonation-banner",
9
+ component: "ImpersonationBanner",
10
+ package: "@karni/impersonation-ui",
11
+ description: "The persistent bar naming both identities during an impersonated session, with Stop."
12
+ }];
13
+ function m(t = e) {
14
+ if (typeof t != "object" || !t) return !1;
15
+ let n = t.registerComponentRole;
16
+ if (typeof n != "function") return !1;
17
+ for (let e of p) n(e.role, e);
18
+ return !0;
21
19
  }
22
- const s = "impersonation-sessions";
23
- function d() {
24
- return [...x.resource(s), "current"];
20
+ //#endregion
21
+ //#region src/hooks.ts
22
+ var h = "impersonation-sessions";
23
+ function g() {
24
+ return [...r.resource(h), "current"];
25
25
  }
26
- function k(n) {
27
- const e = c(), t = (n == null ? void 0 : n.poll) ?? !1;
28
- return S({
29
- queryKey: d(),
30
- queryFn: ({ signal: r }) => e.request(`/${s}/current/`, { signal: r }),
31
- refetchInterval: t === !1 ? !1 : t
32
- });
26
+ function _(e) {
27
+ let t = a(), n = e?.poll ?? !1;
28
+ return l({
29
+ queryKey: g(),
30
+ queryFn: ({ signal: e }) => t.request(`/${h}/current/`, { signal: e }),
31
+ refetchInterval: n !== !1 && n
32
+ });
33
33
  }
34
- function p() {
35
- const n = b(), e = C();
36
- return async (t) => {
37
- await n.adoptTokens(t.access ?? null, t.refresh ?? null), e.removeQueries({
38
- queryKey: ["karni"],
39
- predicate: (r) => r.queryKey[1] !== "$schema"
40
- }), await e.invalidateQueries({ queryKey: d() });
41
- };
34
+ function v() {
35
+ let e = i(), t = u();
36
+ return async (n) => {
37
+ await e.adoptTokens(n.access ?? null, n.refresh ?? null), t.removeQueries({
38
+ queryKey: ["karni"],
39
+ predicate: (e) => e.queryKey[1] !== "$schema"
40
+ }), await t.invalidateQueries({ queryKey: g() });
41
+ };
42
42
  }
43
- function $() {
44
- const n = c(), e = p();
45
- return f({
46
- mutationFn: ({ user: t, reason: r }) => n.request(`/${s}/start/`, {
47
- method: "POST",
48
- body: { user: t, reason: r ?? "" }
49
- }),
50
- onSuccess: (t) => e(t)
51
- });
43
+ function y() {
44
+ let e = a(), t = v();
45
+ return c({
46
+ mutationFn: ({ user: t, reason: n }) => e.request(`/${h}/start/`, {
47
+ method: "POST",
48
+ body: {
49
+ user: t,
50
+ reason: n ?? ""
51
+ }
52
+ }),
53
+ onSuccess: (e) => t(e)
54
+ });
52
55
  }
53
- function E() {
54
- const n = c(), e = p();
55
- return f({
56
- mutationFn: () => n.request(`/${s}/stop/`, { method: "POST", body: {} }),
57
- onSuccess: (t) => e(t)
58
- });
56
+ function b() {
57
+ let e = a(), t = v();
58
+ return c({
59
+ mutationFn: () => e.request(`/${h}/stop/`, {
60
+ method: "POST",
61
+ body: {}
62
+ }),
63
+ onSuccess: (e) => t(e)
64
+ });
59
65
  }
60
- function K(n, e) {
61
- const t = new Date(n).getTime();
62
- return Number.isNaN(t) ? 0 : Math.max(Math.floor((t - e) / 6e4), 0);
66
+ //#endregion
67
+ //#region src/ImpersonationBanner.tsx
68
+ function x(e, t) {
69
+ let n = new Date(e).getTime();
70
+ return Number.isNaN(n) ? 0 : Math.max(Math.floor((n - t) / 6e4), 0);
63
71
  }
64
- function P(n) {
65
- if (n instanceof Error) {
66
- const e = n.data;
67
- if (typeof e == "object" && e !== null && "detail" in e) {
68
- const t = e.detail;
69
- if (typeof t == "string") return t;
70
- }
71
- return n.message;
72
- }
73
- return "Could not stop impersonating.";
72
+ function S(e) {
73
+ if (e instanceof Error) {
74
+ let t = e.data;
75
+ if (typeof t == "object" && t && "detail" in t) {
76
+ let e = t.detail;
77
+ if (typeof e == "string") return e;
78
+ }
79
+ return e.message;
80
+ }
81
+ return "Could not stop impersonating.";
74
82
  }
75
- function A({
76
- poll: n = 6e4,
77
- onStopped: e,
78
- className: t
79
- }) {
80
- var m;
81
- const r = k({ poll: n }), i = E(), [y, g] = w(() => Date.now());
82
- N(() => {
83
- const h = setInterval(() => g(Date.now()), 3e4);
84
- return () => clearInterval(h);
85
- }, []);
86
- const o = ((m = r.data) == null ? void 0 : m.session) ?? null;
87
- if (o === null || !o.active) return null;
88
- const l = K(o.expires_at, y);
89
- return /* @__PURE__ */ a(
90
- "div",
91
- {
92
- role: "status",
93
- "aria-live": "polite",
94
- "data-testid": "impersonation-banner",
95
- className: q(
96
- "fixed inset-x-0 bottom-0 z-50 flex flex-wrap items-center justify-center gap-3 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground shadow-lg",
97
- t
98
- ),
99
- children: [
100
- /* @__PURE__ */ a("span", { children: [
101
- "You are acting as ",
102
- /* @__PURE__ */ u("strong", { children: o.target.email ?? o.target.id }),
103
- " — signed in as ",
104
- o.actor.email ?? o.actor.id,
105
- ".",
106
- " ",
107
- /* @__PURE__ */ a("span", { className: "opacity-80", children: [
108
- l,
109
- " ",
110
- l === 1 ? "minute" : "minutes",
111
- " left"
112
- ] })
113
- ] }),
114
- /* @__PURE__ */ u(
115
- I,
116
- {
117
- size: "sm",
118
- variant: "secondary",
119
- disabled: i.isPending,
120
- onClick: () => i.mutate(void 0, { onSuccess: () => e == null ? void 0 : e() }),
121
- children: i.isPending ? "Stopping…" : "Stop impersonating"
122
- }
123
- ),
124
- i.error !== null && i.error !== void 0 ? /* @__PURE__ */ u("span", { role: "alert", children: P(i.error) }) : null
125
- ]
126
- }
127
- );
83
+ function C({ poll: e = 6e4, onStopped: r, className: i }) {
84
+ let a = _({ poll: e }), c = b(), [l, u] = s(() => Date.now());
85
+ o(() => {
86
+ let e = setInterval(() => u(Date.now()), 3e4);
87
+ return () => clearInterval(e);
88
+ }, []);
89
+ let p = a.data?.session ?? null;
90
+ if (p === null || !p.active) return null;
91
+ let m = x(p.expires_at, l);
92
+ return /* @__PURE__ */ f("div", {
93
+ role: "status",
94
+ "aria-live": "polite",
95
+ "data-testid": "impersonation-banner",
96
+ className: n("fixed inset-x-0 bottom-0 z-50 flex flex-wrap items-center justify-center gap-3 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground shadow-lg", i),
97
+ children: [
98
+ /* @__PURE__ */ f("span", { children: [
99
+ "You are acting as ",
100
+ /* @__PURE__ */ d("strong", { children: p.target.email ?? p.target.id }),
101
+ ", signed in as",
102
+ " ",
103
+ p.actor.email ?? p.actor.id,
104
+ ".",
105
+ " ",
106
+ /* @__PURE__ */ f("span", {
107
+ className: "opacity-80",
108
+ children: [
109
+ m,
110
+ " ",
111
+ m === 1 ? "minute" : "minutes",
112
+ " left"
113
+ ]
114
+ })
115
+ ] }),
116
+ /* @__PURE__ */ d(t, {
117
+ size: "sm",
118
+ variant: "secondary",
119
+ disabled: c.isPending,
120
+ onClick: () => c.mutate(void 0, { onSuccess: () => r?.() }),
121
+ children: c.isPending ? "Stopping…" : "Stop impersonating"
122
+ }),
123
+ c.error !== null && c.error !== void 0 ? /* @__PURE__ */ d("span", {
124
+ role: "alert",
125
+ children: S(c.error)
126
+ }) : null
127
+ ]
128
+ });
128
129
  }
129
- T();
130
- export {
131
- O as IMPERSONATION_COMPONENT_ROLES,
132
- A as ImpersonationBanner,
133
- s as RESOURCE,
134
- d as impersonationKey,
135
- T as registerImpersonationRoles,
136
- k as useImpersonation,
137
- $ as useStartImpersonation,
138
- E as useStopImpersonation
139
- };
140
- //# sourceMappingURL=index.js.map
130
+ //#endregion
131
+ //#region src/index.ts
132
+ m();
133
+ //#endregion
134
+ export { p as IMPERSONATION_COMPONENT_ROLES, C as ImpersonationBanner, h as RESOURCE, g as impersonationKey, m as registerImpersonationRoles, _ as useImpersonation, y as useStartImpersonation, b as useStopImpersonation };
135
+
136
+ //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/roles.ts","../src/hooks.ts","../src/ImpersonationBanner.tsx","../src/index.ts"],"sourcesContent":["/** Component-role registration, feature-detected on @karni/ui's registry. */\nimport * as karniUi from \"@karni/ui\";\n\nexport interface ComponentRoleDefinition {\n role: string;\n component: string;\n package: string;\n description: string;\n}\n\nexport const IMPERSONATION_COMPONENT_ROLES: readonly ComponentRoleDefinition[] = [\n {\n role: \"impersonation-banner\",\n component: \"ImpersonationBanner\",\n package: \"@karni/impersonation-ui\",\n description:\n \"The persistent bar naming both identities during an impersonated session, with Stop.\",\n },\n];\n\ntype RegisterComponentRole = (role: string, definition: ComponentRoleDefinition) => void;\n\nexport function registerImpersonationRoles(registry: unknown = karniUi): boolean {\n if (typeof registry !== \"object\" || registry === null) return false;\n const candidate = (registry as Record<string, unknown>)[\"registerComponentRole\"];\n if (typeof candidate !== \"function\") return false;\n for (const definition of IMPERSONATION_COMPONENT_ROLES) {\n (candidate as RegisterComponentRole)(definition.role, definition);\n }\n return true;\n}\n","/**\n * Hooks over the impersonation API, on @karni/ui's client, auth and query keys.\n *\n * - `useImpersonation()` — `GET /impersonation-sessions/current/`: the session\n * this user is in, or null;\n * - `useStartImpersonation()` — `POST …/start/`; in JWT mode the tokens the\n * backend returns are adopted through `useAuth().adoptTokens`, in session\n * mode `/me/` is re-synced; every resource query is invalidated either way;\n * - `useStopImpersonation()` — `POST …/stop/`, the mirror image.\n */\nimport { karniKeys, useAuth, useKarniClient } from \"@karni/ui\";\nimport {\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationResult,\n type UseQueryResult,\n} from \"@tanstack/react-query\";\nimport type { CurrentResponse, SessionResponse } from \"./types\";\n\nexport const RESOURCE = \"impersonation-sessions\";\n\nexport function impersonationKey(): readonly unknown[] {\n return [...karniKeys.resource(RESOURCE), \"current\"] as const;\n}\n\nexport interface UseImpersonationOptions {\n /** `false` (default) = fetch once; a number = refetch every N milliseconds (watch the clock). */\n poll?: false | number;\n}\n\nexport function useImpersonation(\n options?: UseImpersonationOptions,\n): UseQueryResult<CurrentResponse> {\n const client = useKarniClient();\n const poll = options?.poll ?? false;\n return useQuery<CurrentResponse>({\n queryKey: impersonationKey(),\n queryFn: ({ signal }) => client.request<CurrentResponse>(`/${RESOURCE}/current/`, { signal }),\n refetchInterval: poll === false ? false : poll,\n });\n}\n\nexport interface StartInput {\n /** The user to act as: id or email. */\n user: string;\n reason?: string;\n}\n\nfunction useAdoption(): (response: SessionResponse) => Promise<void> {\n const auth = useAuth();\n const queryClient = useQueryClient();\n return async (response) => {\n await auth.adoptTokens(response.access ?? null, response.refresh ?? null);\n queryClient.removeQueries({\n queryKey: [\"karni\"],\n predicate: (query) => query.queryKey[1] !== \"$schema\",\n });\n await queryClient.invalidateQueries({ queryKey: impersonationKey() });\n };\n}\n\nexport function useStartImpersonation(): UseMutationResult<SessionResponse, unknown, StartInput> {\n const client = useKarniClient();\n const adopt = useAdoption();\n return useMutation<SessionResponse, unknown, StartInput>({\n mutationFn: ({ user, reason }) =>\n client.request<SessionResponse>(`/${RESOURCE}/start/`, {\n method: \"POST\",\n body: { user, reason: reason ?? \"\" },\n }),\n onSuccess: (response) => adopt(response),\n });\n}\n\nexport function useStopImpersonation(): UseMutationResult<SessionResponse, unknown, undefined> {\n const client = useKarniClient();\n const adopt = useAdoption();\n return useMutation<SessionResponse, unknown, undefined>({\n mutationFn: () =>\n client.request<SessionResponse>(`/${RESOURCE}/stop/`, { method: \"POST\", body: {} }),\n onSuccess: (response) => adopt(response),\n });\n}\n","/**\n * <ImpersonationBanner> — the persistent bar naming both identities, with\n * a one-click exit, for a SPA. Renders nothing outside an impersonated\n * session. Mount it once near the root (inside <AuthProvider>).\n *\n * Reads `GET /impersonation-sessions/current/`; Stop posts `…/stop/` and\n * adopts the impersonator's tokens (JWT) or re-syncs `/me/` (session mode).\n */\nimport { Button, cn } from \"@karni/ui\";\nimport { useEffect, useState, type ReactElement } from \"react\";\nimport { useImpersonation, useStopImpersonation } from \"./hooks\";\n\nexport interface ImpersonationBannerProps {\n /** Refetch interval in ms so the minutes left stay honest; default 60 000. */\n poll?: false | number;\n /** Called after a successful stop (e.g. navigate home). */\n onStopped?: () => void;\n className?: string;\n}\n\nfunction minutesLeft(expiresAt: string, now: number): number {\n const end = new Date(expiresAt).getTime();\n if (Number.isNaN(end)) return 0;\n return Math.max(Math.floor((end - now) / 60_000), 0);\n}\n\nfunction errorText(error: unknown): string {\n if (error instanceof Error) {\n const data = (error as { data?: unknown }).data;\n if (typeof data === \"object\" && data !== null && \"detail\" in data) {\n const detail = (data as { detail?: unknown }).detail;\n if (typeof detail === \"string\") return detail;\n }\n return error.message;\n }\n return \"Could not stop impersonating.\";\n}\n\nexport function ImpersonationBanner({\n poll = 60_000,\n onStopped,\n className,\n}: ImpersonationBannerProps): ReactElement | null {\n const query = useImpersonation({ poll });\n const stop = useStopImpersonation();\n const [now, setNow] = useState(() => Date.now());\n useEffect(() => {\n const timer = setInterval(() => setNow(Date.now()), 30_000);\n return () => clearInterval(timer);\n }, []);\n const session = query.data?.session ?? null;\n if (session === null || !session.active) return null;\n const left = minutesLeft(session.expires_at, now);\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n data-testid=\"impersonation-banner\"\n className={cn(\n \"fixed inset-x-0 bottom-0 z-50 flex flex-wrap items-center justify-center gap-3 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground shadow-lg\",\n className,\n )}\n >\n <span>\n You are acting as <strong>{session.target.email ?? session.target.id}</strong> — signed in\n as {session.actor.email ?? session.actor.id}.{\" \"}\n <span className=\"opacity-80\">\n {left} {left === 1 ? \"minute\" : \"minutes\"} left\n </span>\n </span>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={stop.isPending}\n onClick={() => stop.mutate(undefined, { onSuccess: () => onStopped?.() })}\n >\n {stop.isPending ? \"Stopping…\" : \"Stop impersonating\"}\n </Button>\n {stop.error !== null && stop.error !== undefined ? (\n <span role=\"alert\">{errorText(stop.error)}</span>\n ) : null}\n </div>\n );\n}\n","/** @karni/impersonation-ui public barrel: explicit exports only. */\nimport { registerImpersonationRoles } from \"./roles\";\n\nexport { ImpersonationBanner } from \"./ImpersonationBanner\";\nexport type { ImpersonationBannerProps } from \"./ImpersonationBanner\";\n\nexport {\n RESOURCE,\n impersonationKey,\n useImpersonation,\n useStartImpersonation,\n useStopImpersonation,\n} from \"./hooks\";\nexport type { StartInput, UseImpersonationOptions } from \"./hooks\";\n\nexport type {\n CurrentResponse,\n EndReason,\n ImpersonationSession,\n Person,\n SessionResponse,\n} from \"./types\";\n\nexport { IMPERSONATION_COMPONENT_ROLES, registerImpersonationRoles } from \"./roles\";\nexport type { ComponentRoleDefinition } from \"./roles\";\n\nregisterImpersonationRoles();\n"],"names":["IMPERSONATION_COMPONENT_ROLES","registerImpersonationRoles","registry","karniUi","candidate","definition","RESOURCE","impersonationKey","karniKeys","useImpersonation","options","client","useKarniClient","poll","useQuery","signal","useAdoption","auth","useAuth","queryClient","useQueryClient","response","query","useStartImpersonation","adopt","useMutation","user","reason","useStopImpersonation","minutesLeft","expiresAt","now","end","errorText","error","data","detail","ImpersonationBanner","onStopped","className","stop","setNow","useState","useEffect","timer","session","_a","left","jsxs","cn","jsx","Button"],"mappings":";;;;;AAUO,MAAMA,IAAoE;AAAA,EAC/E;AAAA,IACE,MAAM;AAAA,IACN,WAAW;AAAA,IACX,SAAS;AAAA,IACT,aACE;AAAA,EAAA;AAEN;AAIO,SAASC,EAA2BC,IAAoBC,GAAkB;AAC/E,MAAI,OAAOD,KAAa,YAAYA,MAAa,KAAM,QAAO;AAC9D,QAAME,IAAaF,EAAqC;AACxD,MAAI,OAAOE,KAAc,WAAY,QAAO;AAC5C,aAAWC,KAAcL;AACtB,IAAAI,EAAoCC,EAAW,MAAMA,CAAU;AAElE,SAAO;AACT;ACVO,MAAMC,IAAW;AAEjB,SAASC,IAAuC;AACrD,SAAO,CAAC,GAAGC,EAAU,SAASF,CAAQ,GAAG,SAAS;AACpD;AAOO,SAASG,EACdC,GACiC;AACjC,QAAMC,IAASC,EAAA,GACTC,KAAOH,KAAA,gBAAAA,EAAS,SAAQ;AAC9B,SAAOI,EAA0B;AAAA,IAC/B,UAAUP,EAAA;AAAA,IACV,SAAS,CAAC,EAAE,QAAAQ,EAAA,MAAaJ,EAAO,QAAyB,IAAIL,CAAQ,aAAa,EAAE,QAAAS,EAAA,CAAQ;AAAA,IAC5F,iBAAiBF,MAAS,KAAQ,KAAQA;AAAA,EAAA,CAC3C;AACH;AAQA,SAASG,IAA4D;AACnE,QAAMC,IAAOC,EAAA,GACPC,IAAcC,EAAA;AACpB,SAAO,OAAOC,MAAa;AACzB,UAAMJ,EAAK,YAAYI,EAAS,UAAU,MAAMA,EAAS,WAAW,IAAI,GACxEF,EAAY,cAAc;AAAA,MACxB,UAAU,CAAC,OAAO;AAAA,MAClB,WAAW,CAACG,MAAUA,EAAM,SAAS,CAAC,MAAM;AAAA,IAAA,CAC7C,GACD,MAAMH,EAAY,kBAAkB,EAAE,UAAUZ,EAAA,GAAoB;AAAA,EACtE;AACF;AAEO,SAASgB,IAAiF;AAC/F,QAAMZ,IAASC,EAAA,GACTY,IAAQR,EAAA;AACd,SAAOS,EAAkD;AAAA,IACvD,YAAY,CAAC,EAAE,MAAAC,GAAM,QAAAC,QACnBhB,EAAO,QAAyB,IAAIL,CAAQ,WAAW;AAAA,MACrD,QAAQ;AAAA,MACR,MAAM,EAAE,MAAAoB,GAAM,QAAQC,KAAU,GAAA;AAAA,IAAG,CACpC;AAAA,IACH,WAAW,CAACN,MAAaG,EAAMH,CAAQ;AAAA,EAAA,CACxC;AACH;AAEO,SAASO,IAA+E;AAC7F,QAAMjB,IAASC,EAAA,GACTY,IAAQR,EAAA;AACd,SAAOS,EAAiD;AAAA,IACtD,YAAY,MACVd,EAAO,QAAyB,IAAIL,CAAQ,UAAU,EAAE,QAAQ,QAAQ,MAAM,CAAA,GAAI;AAAA,IACpF,WAAW,CAACe,MAAaG,EAAMH,CAAQ;AAAA,EAAA,CACxC;AACH;AC/DA,SAASQ,EAAYC,GAAmBC,GAAqB;AAC3D,QAAMC,IAAM,IAAI,KAAKF,CAAS,EAAE,QAAA;AAChC,SAAI,OAAO,MAAME,CAAG,IAAU,IACvB,KAAK,IAAI,KAAK,OAAOA,IAAMD,KAAO,GAAM,GAAG,CAAC;AACrD;AAEA,SAASE,EAAUC,GAAwB;AACzC,MAAIA,aAAiB,OAAO;AAC1B,UAAMC,IAAQD,EAA6B;AAC3C,QAAI,OAAOC,KAAS,YAAYA,MAAS,QAAQ,YAAYA,GAAM;AACjE,YAAMC,IAAUD,EAA8B;AAC9C,UAAI,OAAOC,KAAW,SAAU,QAAOA;AAAA,IACzC;AACA,WAAOF,EAAM;AAAA,EACf;AACA,SAAO;AACT;AAEO,SAASG,EAAoB;AAAA,EAClC,MAAAxB,IAAO;AAAA,EACP,WAAAyB;AAAA,EACA,WAAAC;AACF,GAAkD;;AAChD,QAAMjB,IAAQb,EAAiB,EAAE,MAAAI,GAAM,GACjC2B,IAAOZ,EAAA,GACP,CAACG,GAAKU,CAAM,IAAIC,EAAS,MAAM,KAAK,KAAK;AAC/C,EAAAC,EAAU,MAAM;AACd,UAAMC,IAAQ,YAAY,MAAMH,EAAO,KAAK,IAAA,CAAK,GAAG,GAAM;AAC1D,WAAO,MAAM,cAAcG,CAAK;AAAA,EAClC,GAAG,CAAA,CAAE;AACL,QAAMC,MAAUC,IAAAxB,EAAM,SAAN,gBAAAwB,EAAY,YAAW;AACvC,MAAID,MAAY,QAAQ,CAACA,EAAQ,OAAQ,QAAO;AAChD,QAAME,IAAOlB,EAAYgB,EAAQ,YAAYd,CAAG;AAChD,SACE,gBAAAiB;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,MAAK;AAAA,MACL,aAAU;AAAA,MACV,eAAY;AAAA,MACZ,WAAWC;AAAA,QACT;AAAA,QACAV;AAAA,MAAA;AAAA,MAGF,UAAA;AAAA,QAAA,gBAAAS,EAAC,QAAA,EAAK,UAAA;AAAA,UAAA;AAAA,4BACe,UAAA,EAAQ,UAAAH,EAAQ,OAAO,SAASA,EAAQ,OAAO,IAAG;AAAA,UAAS;AAAA,UAC1EA,EAAQ,MAAM,SAASA,EAAQ,MAAM;AAAA,UAAG;AAAA,UAAE;AAAA,UAC9C,gBAAAG,EAAC,QAAA,EAAK,WAAU,cACb,UAAA;AAAA,YAAAD;AAAA,YAAK;AAAA,YAAEA,MAAS,IAAI,WAAW;AAAA,YAAU;AAAA,UAAA,EAAA,CAC5C;AAAA,QAAA,GACF;AAAA,QACA,gBAAAG;AAAA,UAACC;AAAA,UAAA;AAAA,YACC,MAAK;AAAA,YACL,SAAQ;AAAA,YACR,UAAUX,EAAK;AAAA,YACf,SAAS,MAAMA,EAAK,OAAO,QAAW,EAAE,WAAW,MAAMF,KAAA,gBAAAA,KAAe;AAAA,YAEvE,UAAAE,EAAK,YAAY,cAAc;AAAA,UAAA;AAAA,QAAA;AAAA,QAEjCA,EAAK,UAAU,QAAQA,EAAK,UAAU,SACrC,gBAAAU,EAAC,QAAA,EAAK,MAAK,SAAS,UAAAjB,EAAUO,EAAK,KAAK,GAAE,IACxC;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAGV;ACzDAvC,EAAA;"}
1
+ {"version":3,"file":"index.js","names":["karniUi","IMPERSONATION_COMPONENT_ROLES","registerImpersonationRoles","registry","candidate","\"registerComponentRole\"","definition","RESOURCE","impersonationKey","karniKeys","useImpersonation","client","useKarniClient","poll","options","useQuery","useAdoption","auth","useAuth","queryClient","useQueryClient","response","query","useStartImpersonation","adopt","useMutation","reason","useStopImpersonation","minutesLeft","end","expiresAt","now","errorText","error","data","detail","ImpersonationBanner","stop","setNow","useState","timer","session","left","cn","className","Button","onStopped"],"sources":["../src/roles.ts","../src/hooks.ts","../src/ImpersonationBanner.tsx","../src/index.ts"],"sourcesContent":["/** Component-role registration, feature-detected on @karni/ui's registry. */\nimport * as karniUi from \"@karni/ui\";\n\nexport interface ComponentRoleDefinition {\n role: string;\n component: string;\n package: string;\n description: string;\n}\n\nexport const IMPERSONATION_COMPONENT_ROLES: readonly ComponentRoleDefinition[] = [\n {\n role: \"impersonation-banner\",\n component: \"ImpersonationBanner\",\n package: \"@karni/impersonation-ui\",\n description:\n \"The persistent bar naming both identities during an impersonated session, with Stop.\",\n },\n];\n\ntype RegisterComponentRole = (role: string, definition: ComponentRoleDefinition) => void;\n\nexport function registerImpersonationRoles(registry: unknown = karniUi): boolean {\n if (typeof registry !== \"object\" || registry === null) return false;\n const candidate = (registry as Record<string, unknown>)[\"registerComponentRole\"];\n if (typeof candidate !== \"function\") return false;\n for (const definition of IMPERSONATION_COMPONENT_ROLES) {\n (candidate as RegisterComponentRole)(definition.role, definition);\n }\n return true;\n}\n","/**\n * Hooks over the impersonation API, on @karni/ui's client, auth and query keys.\n *\n * - `useImpersonation()`: `GET /impersonation-sessions/current/`, the session\n * this user is in, or null.\n * - `useStartImpersonation()`: `POST …/start/`. In JWT mode the tokens the\n * backend returns are adopted through `useAuth().adoptTokens`, and in session\n * mode `/me/` is re-synced. Every resource query is invalidated either way.\n * - `useStopImpersonation()`: `POST …/stop/`, the mirror image.\n */\nimport { karniKeys, useAuth, useKarniClient } from \"@karni/ui\";\nimport {\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationResult,\n type UseQueryResult,\n} from \"@tanstack/react-query\";\nimport type { CurrentResponse, SessionResponse } from \"./types\";\n\nexport const RESOURCE = \"impersonation-sessions\";\n\nexport function impersonationKey(): readonly unknown[] {\n return [...karniKeys.resource(RESOURCE), \"current\"] as const;\n}\n\nexport interface UseImpersonationOptions {\n /** `false` (default) = fetch once, a number = refetch every N milliseconds (watch the clock). */\n poll?: false | number;\n}\n\nexport function useImpersonation(\n options?: UseImpersonationOptions,\n): UseQueryResult<CurrentResponse> {\n const client = useKarniClient();\n const poll = options?.poll ?? false;\n return useQuery<CurrentResponse>({\n queryKey: impersonationKey(),\n queryFn: ({ signal }) => client.request<CurrentResponse>(`/${RESOURCE}/current/`, { signal }),\n refetchInterval: poll === false ? false : poll,\n });\n}\n\nexport interface StartInput {\n /** The user to act as: id or email. */\n user: string;\n reason?: string;\n}\n\nfunction useAdoption(): (response: SessionResponse) => Promise<void> {\n const auth = useAuth();\n const queryClient = useQueryClient();\n return async (response) => {\n await auth.adoptTokens(response.access ?? null, response.refresh ?? null);\n queryClient.removeQueries({\n queryKey: [\"karni\"],\n predicate: (query) => query.queryKey[1] !== \"$schema\",\n });\n await queryClient.invalidateQueries({ queryKey: impersonationKey() });\n };\n}\n\nexport function useStartImpersonation(): UseMutationResult<SessionResponse, unknown, StartInput> {\n const client = useKarniClient();\n const adopt = useAdoption();\n return useMutation<SessionResponse, unknown, StartInput>({\n mutationFn: ({ user, reason }) =>\n client.request<SessionResponse>(`/${RESOURCE}/start/`, {\n method: \"POST\",\n body: { user, reason: reason ?? \"\" },\n }),\n onSuccess: (response) => adopt(response),\n });\n}\n\nexport function useStopImpersonation(): UseMutationResult<SessionResponse, unknown, undefined> {\n const client = useKarniClient();\n const adopt = useAdoption();\n return useMutation<SessionResponse, unknown, undefined>({\n mutationFn: () =>\n client.request<SessionResponse>(`/${RESOURCE}/stop/`, { method: \"POST\", body: {} }),\n onSuccess: (response) => adopt(response),\n });\n}\n","/**\n * <ImpersonationBanner>: the persistent bar naming both identities, with\n * a one-click exit, for a SPA. Renders nothing outside an impersonated\n * session. Mount it once near the root (inside <AuthProvider>).\n *\n * Reads `GET /impersonation-sessions/current/`. Stop posts `…/stop/` and\n * adopts the impersonator's tokens (JWT) or re-syncs `/me/` (session mode).\n */\nimport { Button, cn } from \"@karni/ui\";\nimport { useEffect, useState, type ReactElement } from \"react\";\nimport { useImpersonation, useStopImpersonation } from \"./hooks\";\n\nexport interface ImpersonationBannerProps {\n /** Refetch interval in ms so the minutes left stay honest. Default 60 000. */\n poll?: false | number;\n /** Called after a successful stop (e.g. navigate home). */\n onStopped?: () => void;\n className?: string;\n}\n\nfunction minutesLeft(expiresAt: string, now: number): number {\n const end = new Date(expiresAt).getTime();\n if (Number.isNaN(end)) return 0;\n return Math.max(Math.floor((end - now) / 60_000), 0);\n}\n\nfunction errorText(error: unknown): string {\n if (error instanceof Error) {\n const data = (error as { data?: unknown }).data;\n if (typeof data === \"object\" && data !== null && \"detail\" in data) {\n const detail = (data as { detail?: unknown }).detail;\n if (typeof detail === \"string\") return detail;\n }\n return error.message;\n }\n return \"Could not stop impersonating.\";\n}\n\nexport function ImpersonationBanner({\n poll = 60_000,\n onStopped,\n className,\n}: ImpersonationBannerProps): ReactElement | null {\n const query = useImpersonation({ poll });\n const stop = useStopImpersonation();\n const [now, setNow] = useState(() => Date.now());\n useEffect(() => {\n const timer = setInterval(() => setNow(Date.now()), 30_000);\n return () => clearInterval(timer);\n }, []);\n const session = query.data?.session ?? null;\n if (session === null || !session.active) return null;\n const left = minutesLeft(session.expires_at, now);\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n data-testid=\"impersonation-banner\"\n className={cn(\n \"fixed inset-x-0 bottom-0 z-50 flex flex-wrap items-center justify-center gap-3 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground shadow-lg\",\n className,\n )}\n >\n <span>\n You are acting as <strong>{session.target.email ?? session.target.id}</strong>, signed in as{\" \"}\n {session.actor.email ?? session.actor.id}.{\" \"}\n <span className=\"opacity-80\">\n {left} {left === 1 ? \"minute\" : \"minutes\"} left\n </span>\n </span>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={stop.isPending}\n onClick={() => stop.mutate(undefined, { onSuccess: () => onStopped?.() })}\n >\n {stop.isPending ? \"Stopping…\" : \"Stop impersonating\"}\n </Button>\n {stop.error !== null && stop.error !== undefined ? (\n <span role=\"alert\">{errorText(stop.error)}</span>\n ) : null}\n </div>\n );\n}\n","/** @karni/impersonation-ui public barrel: explicit exports only. */\nimport { registerImpersonationRoles } from \"./roles\";\n\nexport { ImpersonationBanner } from \"./ImpersonationBanner\";\nexport type { ImpersonationBannerProps } from \"./ImpersonationBanner\";\n\nexport {\n RESOURCE,\n impersonationKey,\n useImpersonation,\n useStartImpersonation,\n useStopImpersonation,\n} from \"./hooks\";\nexport type { StartInput, UseImpersonationOptions } from \"./hooks\";\n\nexport type {\n CurrentResponse,\n EndReason,\n ImpersonationSession,\n Person,\n SessionResponse,\n} from \"./types\";\n\nexport { IMPERSONATION_COMPONENT_ROLES, registerImpersonationRoles } from \"./roles\";\nexport type { ComponentRoleDefinition } from \"./roles\";\n\nregisterImpersonationRoles();\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import { ReactElement } from 'react';
2
2
  export interface ImpersonationBannerProps {
3
- /** Refetch interval in ms so the minutes left stay honest; default 60 000. */
3
+ /** Refetch interval in ms so the minutes left stay honest. Default 60 000. */
4
4
  poll?: false | number;
5
5
  /** Called after a successful stop (e.g. navigate home). */
6
6
  onStopped?: () => void;
@@ -3,7 +3,7 @@ import { CurrentResponse, SessionResponse } from './types';
3
3
  export declare const RESOURCE = "impersonation-sessions";
4
4
  export declare function impersonationKey(): readonly unknown[];
5
5
  export interface UseImpersonationOptions {
6
- /** `false` (default) = fetch once; a number = refetch every N milliseconds (watch the clock). */
6
+ /** `false` (default) = fetch once, a number = refetch every N milliseconds (watch the clock). */
7
7
  poll?: false | number;
8
8
  }
9
9
  export declare function useImpersonation(options?: UseImpersonationOptions): UseQueryResult<CurrentResponse>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karni/impersonation-ui",
3
- "version": "0.3.1",
4
- "description": "Impersonation components for Karni backends (the Django + React framework): ImpersonationBanner and useImpersonation composing @karni/ui, for the karni-impersonation module.",
3
+ "version": "0.4.0",
4
+ "description": "React components for the Karni impersonation module: ImpersonationBanner and its hook. Karni is an open-source Django + React framework for building with AI coding agents. Your agent writes only your domain. The API, admin, permissions and React screens come from a package you upgrade, not code it rewrites in each project. One rule set covers Claude Code, Cursor and Copilot. The build fails when an agent hand-writes what Karni ships.",
5
5
  "keywords": [
6
6
  "react",
7
7
  "django",
@@ -50,7 +50,7 @@
50
50
  "format:check": "prettier --check ."
51
51
  },
52
52
  "peerDependencies": {
53
- "@karni/ui": "^0.3.0",
53
+ "@karni/ui": "^0.4.0",
54
54
  "@tanstack/react-query": "^5.0.0",
55
55
  "react": "^18.0.0 || ^19.0.0",
56
56
  "react-dom": "^18.0.0 || ^19.0.0"
@@ -61,12 +61,12 @@
61
61
  "@testing-library/jest-dom": "^6.5.0",
62
62
  "@testing-library/react": "^16.0.1",
63
63
  "@testing-library/user-event": "^14.5.2",
64
- "@types/node": "^20.16.0",
64
+ "@types/node": "^24.0.0",
65
65
  "@types/react": "^18.3.5",
66
66
  "@types/react-dom": "^18.3.0",
67
67
  "@typescript-eslint/eslint-plugin": "^8.71.2",
68
68
  "@typescript-eslint/parser": "^8.71.2",
69
- "@vitejs/plugin-react": "^4.7.0",
69
+ "@vitejs/plugin-react": "^6.1.2",
70
70
  "eslint": "^8.57.0",
71
71
  "eslint-config-prettier": "^9.1.0",
72
72
  "eslint-plugin-react-hooks": "^4.6.2",
@@ -76,9 +76,9 @@
76
76
  "react": "^18.3.1",
77
77
  "react-dom": "^18.3.1",
78
78
  "typescript": "^5.6.2",
79
- "vite": "^6.4.4",
79
+ "vite": "^8.3.4",
80
80
  "vite-plugin-dts": "^5.1.2",
81
- "vitest": "^4.1.12"
81
+ "vitest": "^5.0.5"
82
82
  },
83
83
  "publishConfig": {
84
84
  "access": "public",