@karni/impersonation-ui 0.3.1 → 0.5.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 +20 -9
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +127 -131
- package/dist/index.js.map +1 -1
- package/dist/src/ImpersonationBanner.d.ts +1 -1
- package/dist/src/hooks.d.ts +1 -1
- package/package.json +10 -7
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# @karni/impersonation-ui
|
|
2
2
|
|
|
3
|
-
React
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
React components for the Karni impersonation module: ImpersonationBanner and its hook.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@karni/impersonation-ui) [](https://github.com/cbsshekhawat18-lab/karni/blob/main/LICENSE) [](https://cbsshekhawat18-lab.github.io/karni/modules/#modules-in-this-repository)
|
|
6
|
+
|
|
7
|
+
It composes `@karni/ui` for the [karni-impersonation](https://pypi.org/project/karni-impersonation/) module: the banner a single-page app shows while a support user acts as someone else, and the hooks to start and stop.
|
|
6
8
|
|
|
7
9
|
```sh
|
|
8
10
|
npm install @karni/impersonation-ui
|
|
@@ -21,16 +23,25 @@ start.mutate({ user: "customer@example.com", reason: "ticket #7" });
|
|
|
21
23
|
```
|
|
22
24
|
|
|
23
25
|
- `<ImpersonationBanner>` reads `GET /api/impersonation-sessions/current/`
|
|
24
|
-
and renders nothing outside a session
|
|
25
|
-
identities, the minutes left, and **Stop impersonating
|
|
26
|
-
`…/stop/`, adopts the impersonator's tokens (JWT mode
|
|
26
|
+
and renders nothing outside a session. Inside one it names both
|
|
27
|
+
identities, the minutes left, and **Stop impersonating**. That button posts
|
|
28
|
+
`…/stop/`, adopts the impersonator's tokens (JWT mode, through
|
|
27
29
|
`useAuth().adoptTokens`) or re-syncs `/me/` (session mode), and drops every
|
|
28
30
|
cached resource query so the app re-reads as the right person.
|
|
29
31
|
- `useStartImpersonation()` does the same in the other direction after
|
|
30
32
|
`POST …/start/` (the backend enforces the permission, the no-escalation
|
|
31
|
-
rule and the reason
|
|
33
|
+
rule and the reason, and its refusal is the mutation's error).
|
|
32
34
|
- `useImpersonation()` is the query behind the banner.
|
|
33
35
|
|
|
34
|
-
Peer dependencies: `@karni/ui
|
|
35
|
-
|
|
36
|
+
Peer dependencies: `@karni/ui` at this package's version (every Karni package is released at one version), React 18/19, `@tanstack/react-query` 5.
|
|
37
|
+
It uses tokens only, and registers the `impersonation-banner` role on `@karni/ui`'s role
|
|
36
38
|
registry when it is present.
|
|
39
|
+
|
|
40
|
+
## Links
|
|
41
|
+
|
|
42
|
+
- The module in the docs: <https://cbsshekhawat18-lab.github.io/karni/modules/#modules-in-this-repository>
|
|
43
|
+
- The Python module: <https://pypi.org/project/karni-impersonation/>
|
|
44
|
+
- Karni, the framework this package is part of: <https://github.com/cbsshekhawat18-lab/karni>
|
|
45
|
+
- Changelog: <https://github.com/cbsshekhawat18-lab/karni/blob/main/CHANGELOG.md>
|
|
46
|
+
- Issues: <https://github.com/cbsshekhawat18-lab/karni/issues>
|
|
47
|
+
- License: Apache-2.0. @karni/impersonation-ui is released with every other Karni package, at one version.
|
package/dist/index.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
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
|
package/dist/index.cjs.map
CHANGED
|
@@ -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":"4dAUaA,EAAoE,CAC/E,CACE,KAAM,uBACN,UAAW,sBACX,QAAS,0BACT,YACE,sFAAA,CAEN,EAIO,SAASC,EAA2BC,EAAoBC,EAAkB,CAC/E,GAAI,OAAOD,GAAa,UAAYA,IAAa,KAAM,MAAO,GAC9D,MAAME,EAAaF,EAAqC,sBACxD,GAAI,OAAOE,GAAc,WAAY,MAAO,GAC5C,UAAWC,KAAcL,EACtBI,EAAoCC,EAAW,KAAMA,CAAU,EAElE,MAAO,EACT,CCVO,MAAMC,EAAW,yBAEjB,SAASC,GAAuC,CACrD,MAAO,CAAC,GAAGC,EAAAA,UAAU,SAASF,CAAQ,EAAG,SAAS,CACpD,CAOO,SAASG,EACdC,EACiC,CACjC,MAAMC,EAASC,EAAAA,eAAA,EACTC,GAAOH,GAAA,YAAAA,EAAS,OAAQ,GAC9B,OAAOI,WAA0B,CAC/B,SAAUP,EAAA,EACV,QAAS,CAAC,CAAE,OAAAQ,CAAA,IAAaJ,EAAO,QAAyB,IAAIL,CAAQ,YAAa,CAAE,OAAAS,CAAA,CAAQ,EAC5F,gBAAiBF,IAAS,GAAQ,GAAQA,CAAA,CAC3C,CACH,CAQA,SAASG,GAA4D,CACnE,MAAMC,EAAOC,EAAAA,QAAA,EACPC,EAAcC,EAAAA,eAAA,EACpB,MAAO,OAAOC,GAAa,CACzB,MAAMJ,EAAK,YAAYI,EAAS,QAAU,KAAMA,EAAS,SAAW,IAAI,EACxEF,EAAY,cAAc,CACxB,SAAU,CAAC,OAAO,EAClB,UAAYG,GAAUA,EAAM,SAAS,CAAC,IAAM,SAAA,CAC7C,EACD,MAAMH,EAAY,kBAAkB,CAAE,SAAUZ,EAAA,EAAoB,CACtE,CACF,CAEO,SAASgB,GAAiF,CAC/F,MAAMZ,EAASC,EAAAA,eAAA,EACTY,EAAQR,EAAA,EACd,OAAOS,cAAkD,CACvD,WAAY,CAAC,CAAE,KAAAC,EAAM,OAAAC,KACnBhB,EAAO,QAAyB,IAAIL,CAAQ,UAAW,CACrD,OAAQ,OACR,KAAM,CAAE,KAAAoB,EAAM,OAAQC,GAAU,EAAA,CAAG,CACpC,EACH,UAAYN,GAAaG,EAAMH,CAAQ,CAAA,CACxC,CACH,CAEO,SAASO,GAA+E,CAC7F,MAAMjB,EAASC,EAAAA,eAAA,EACTY,EAAQR,EAAA,EACd,OAAOS,cAAiD,CACtD,WAAY,IACVd,EAAO,QAAyB,IAAIL,CAAQ,SAAU,CAAE,OAAQ,OAAQ,KAAM,CAAA,EAAI,EACpF,UAAYe,GAAaG,EAAMH,CAAQ,CAAA,CACxC,CACH,CC/DA,SAASQ,EAAYC,EAAmBC,EAAqB,CAC3D,MAAMC,EAAM,IAAI,KAAKF,CAAS,EAAE,QAAA,EAChC,OAAI,OAAO,MAAME,CAAG,EAAU,EACvB,KAAK,IAAI,KAAK,OAAOA,EAAMD,GAAO,GAAM,EAAG,CAAC,CACrD,CAEA,SAASE,EAAUC,EAAwB,CACzC,GAAIA,aAAiB,MAAO,CAC1B,MAAMC,EAAQD,EAA6B,KAC3C,GAAI,OAAOC,GAAS,UAAYA,IAAS,MAAQ,WAAYA,EAAM,CACjE,MAAMC,EAAUD,EAA8B,OAC9C,GAAI,OAAOC,GAAW,SAAU,OAAOA,CACzC,CACA,OAAOF,EAAM,OACf,CACA,MAAO,+BACT,CAEO,SAASG,EAAoB,CAClC,KAAAxB,EAAO,IACP,UAAAyB,EACA,UAAAC,CACF,EAAkD,OAChD,MAAMjB,EAAQb,EAAiB,CAAE,KAAAI,EAAM,EACjC2B,EAAOZ,EAAA,EACP,CAACG,EAAKU,CAAM,EAAIC,EAAAA,SAAS,IAAM,KAAK,KAAK,EAC/CC,EAAAA,UAAU,IAAM,CACd,MAAMC,EAAQ,YAAY,IAAMH,EAAO,KAAK,IAAA,CAAK,EAAG,GAAM,EAC1D,MAAO,IAAM,cAAcG,CAAK,CAClC,EAAG,CAAA,CAAE,EACL,MAAMC,IAAUC,EAAAxB,EAAM,OAAN,YAAAwB,EAAY,UAAW,KACvC,GAAID,IAAY,MAAQ,CAACA,EAAQ,OAAQ,OAAO,KAChD,MAAME,EAAOlB,EAAYgB,EAAQ,WAAYd,CAAG,EAChD,OACEiB,EAAAA,KAAC,MAAA,CACC,KAAK,SACL,YAAU,SACV,cAAY,uBACZ,UAAWC,EAAAA,GACT,oKACAV,CAAA,EAGF,SAAA,CAAAS,OAAC,OAAA,CAAK,SAAA,CAAA,2BACe,SAAA,CAAQ,SAAAH,EAAQ,OAAO,OAASA,EAAQ,OAAO,GAAG,EAAS,mBAC1EA,EAAQ,MAAM,OAASA,EAAQ,MAAM,GAAG,IAAE,IAC9CG,EAAAA,KAAC,OAAA,CAAK,UAAU,aACb,SAAA,CAAAD,EAAK,IAAEA,IAAS,EAAI,SAAW,UAAU,OAAA,CAAA,CAC5C,CAAA,EACF,EACAG,EAAAA,IAACC,EAAAA,OAAA,CACC,KAAK,KACL,QAAQ,YACR,SAAUX,EAAK,UACf,QAAS,IAAMA,EAAK,OAAO,OAAW,CAAE,UAAW,IAAMF,GAAA,YAAAA,IAAe,EAEvE,SAAAE,EAAK,UAAY,YAAc,oBAAA,CAAA,EAEjCA,EAAK,QAAU,MAAQA,EAAK,QAAU,OACrCU,MAAC,OAAA,CAAK,KAAK,QAAS,SAAAjB,EAAUO,EAAK,KAAK,EAAE,EACxC,IAAA,CAAA,CAAA,CAGV,CCzDAvC,EAAA"}
|
|
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
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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":"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"}
|
|
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
|
|
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;
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.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 every coding agent. 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.
|
|
53
|
+
"@karni/ui": "^0.5.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": "^
|
|
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": "^
|
|
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,12 +76,15 @@
|
|
|
76
76
|
"react": "^18.3.1",
|
|
77
77
|
"react-dom": "^18.3.1",
|
|
78
78
|
"typescript": "^5.6.2",
|
|
79
|
-
"vite": "^
|
|
79
|
+
"vite": "^8.3.4",
|
|
80
80
|
"vite-plugin-dts": "^5.1.2",
|
|
81
|
-
"vitest": "^
|
|
81
|
+
"vitest": "^5.0.5"
|
|
82
82
|
},
|
|
83
83
|
"publishConfig": {
|
|
84
84
|
"access": "public",
|
|
85
85
|
"provenance": true
|
|
86
|
+
},
|
|
87
|
+
"overrides": {
|
|
88
|
+
"minimatch@>=7.0.0 <11.0.1": "^11.0.5"
|
|
86
89
|
}
|
|
87
90
|
}
|