Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion example/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import appCssUrl from '../app.css?url';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import { Suspense } from 'react';
import { getSignInUrl } from '@workos/authkit-tanstack-react-start';
import { AuthKitProvider, getAuthAction } from '@workos/authkit-tanstack-react-start/client';
import { AuthKitProvider, Impersonation, getAuthAction } from '@workos/authkit-tanstack-react-start/client';
import Footer from '../components/footer';
import SignInButton from '../components/sign-in-button';
import type { ReactNode } from 'react';
Expand Down Expand Up @@ -84,6 +84,7 @@ function RootComponent() {
</Flex>
</Container>
</Theme>
<Impersonation />
<TanStackRouterDevtools position="bottom-right" />
</AuthKitProvider>
</RootDocument>
Expand Down
27 changes: 27 additions & 0 deletions src/client/components/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ComponentPropsWithoutRef } from 'react';

export function Button(props: ComponentPropsWithoutRef<'button'>) {
return (
<button
type="button"
{...props}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
height: '1.714em',
padding: '0 0.6em',

fontFamily: 'inherit',
fontSize: 'inherit',
borderRadius: 'min(max(calc(var(--wi-s) * 0.6), 1px), 7px)',
border: 'none',
backgroundColor: 'var(--wi-c)',
color: 'white',

...props.style,
}}
/>
);
}
163 changes: 163 additions & 0 deletions src/client/components/impersonation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useEffect, useState, type ComponentPropsWithoutRef } from 'react';
import { Button } from './button.js';
import { MinMaxButton } from './min-max-button.js';
import { getOrganizationAction, type OrganizationInfo } from '../../server/actions.js';
import { useAuth } from '../AuthKitProvider.js';

interface ImpersonationProps extends ComponentPropsWithoutRef<'div'> {
side?: 'top' | 'bottom';
returnTo?: string;
}

export function Impersonation({ side = 'bottom', returnTo, ...props }: ImpersonationProps) {
const { user, impersonator, organizationId, signOut } = useAuth();

const [organization, setOrganization] = useState<OrganizationInfo | null>(null);

useEffect(() => {
if (!organizationId || !impersonator || !user) return;
if (organization && organization.id === organizationId) return;
getOrganizationAction({ data: organizationId }).then(setOrganization).catch(() => { console.error(`${organizationId} not found!`});
}, [organizationId, impersonator, user]);

if (!impersonator || !user) return null;

return (
<div
{...props}
data-workos-impersonation-root=""
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 9999,

// short properties with defaults for authoring convenience
'--wi-minimized': '0',
'--wi-s': 'min(max(var(--workos-impersonation-size, 4px), 2px), 15px)',
'--wi-bgc': 'var(--workos-impersonation-background-color, #fce654)',
'--wi-c': 'var(--workos-impersonation-color, #1a1600)',
'--wi-bc': 'var(--workos-impersonation-border-color, #e0c36c)',
'--wi-bw': 'var(--workos-impersonation-border-width, 1px)',

...props.style,
}}
>
<div
style={{
'--wi-frame-size': 'calc(var(--wi-s) * (1 - var(--wi-minimized)) + var(--wi-minimized) * var(--wi-bw) * -1)',
position: 'absolute',
inset: 'calc(var(--wi-frame-size) * -1)',
borderRadius: 'calc(var(--wi-frame-size) * 3)',
boxShadow: `
inset 0 0 0 calc(var(--wi-frame-size) * 2) var(--wi-bgc),
inset 0 0 0 calc(var(--wi-frame-size) * 2 + var(--wi-bw)) var(--wi-bc)
`,
transition: 'all 500ms cubic-bezier(0.16, 1, 0.3, 1)',
}}
/>

<div
style={{
display: 'flex',
justifyContent: 'center',

position: 'fixed',
left: 0,
right: 0,
...(side === 'top' && { top: 'var(--wi-s)' }),
...(side === 'bottom' && { bottom: 'var(--wi-s)' }),

fontFamily:
"system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif",
fontSize: 'calc(12px + var(--wi-s) * 0.5)',
lineHeight: '1.4',
}}
>
<form
onSubmit={async (event) => {
event.preventDefault();
await signOut({ returnTo });
}}
style={{
display: 'flex',
alignItems: 'baseline',
paddingLeft: 'var(--wi-s)',
paddingRight: 'var(--wi-s)',

position: 'relative',
marginLeft: 'calc(var(--wi-s) * 2)',
marginRight: 'calc(var(--wi-s) * 2)',

pointerEvents: 'auto',
backgroundColor: 'var(--wi-bgc)',
borderStyle: 'solid',
borderColor: 'var(--wi-bc)',
borderLeftWidth: 'var(--wi-bw)',
borderRightWidth: 'var(--wi-bw)',

transition: 'all 500ms cubic-bezier(0.16, 1, 0.3, 1)',
transform: `translateX(calc(var(--wi-minimized) * (var(--wi-s) * 10 - 5%)))`,
opacity: 'calc(1 - var(--wi-minimized))',
zIndex: 'calc(1 - var(--wi-minimized))',

...(side === 'top' && {
paddingTop: 0,
paddingBottom: 'var(--wi-s)',
borderTopWidth: 0,
borderBottomWidth: 'var(--wi-bw)',
borderBottomLeftRadius: 'var(--wi-s)',
borderBottomRightRadius: 'var(--wi-s)',
}),

...(side === 'bottom' && {
paddingTop: 'var(--wi-s)',
paddingBottom: 0,
borderTopWidth: 'var(--wi-bw)',
borderBottomWidth: 0,
borderTopLeftRadius: 'var(--wi-s)',
borderTopRightRadius: 'var(--wi-s)',
}),
}}
>
<p style={{ all: 'unset', color: 'var(--wi-c)', textWrap: 'balance', marginLeft: 'var(--wi-s)' }}>
You are impersonating <b>{user.email}</b>{' '}
{organization !== null && (
<>
within the <b>{organization.name}</b> organization
</>
)}
</p>
<Button type="submit" style={{ marginLeft: 'calc(var(--wi-s) * 2)', marginRight: 'var(--wi-s)' }}>
Stop
</Button>
<MinMaxButton minimizedValue="1">{side === 'top' ? '↗' : '↘'}</MinMaxButton>
</form>

<div
style={{
padding: 'var(--wi-s)',

position: 'fixed',
right: 'var(--wi-s)',

pointerEvents: 'auto',
backgroundColor: 'var(--wi-bgc)',
border: 'var(--wi-bw) solid var(--wi-bc)',
borderRadius: 'var(--wi-s)',

transition: 'all 500ms cubic-bezier(0.16, 1, 0.3, 1)',
transform: 'translateX(calc((1 - var(--wi-minimized)) * var(--wi-s) * -5))',
opacity: 'var(--wi-minimized)',
zIndex: 'var(--wi-minimized)',

...(side === 'top' && { top: 'var(--wi-s)' }),
...(side === 'bottom' && { bottom: 'var(--wi-s)' }),
}}
>
<MinMaxButton minimizedValue="0">{side === 'top' ? '↙' : '↖'}</MinMaxButton>
</div>
</div>
</div>
);
}
21 changes: 21 additions & 0 deletions src/client/components/min-max-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
import { Button } from './button.js';

interface MinMaxButtonProps {
children?: ReactNode;
minimizedValue: '0' | '1';
}

export function MinMaxButton({ children, minimizedValue }: MinMaxButtonProps) {
return (
<Button
onClick={() => {
const root = document.querySelector('[data-workos-impersonation-root]') as HTMLElement | null;
root?.style.setProperty('--wi-minimized', minimizedValue);
}}
style={{ padding: 0, width: '1.714em' }}
>
{children}
</Button>
);
}
165 changes: 165 additions & 0 deletions src/client/impersonation.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, act, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Impersonation } from './components/impersonation';
import { useAuth } from './AuthKitProvider';
import { getOrganizationAction } from '../server/actions';

vi.mock('./AuthKitProvider', () => ({
useAuth: vi.fn(),
}));

vi.mock('../server/actions', () => ({
getOrganizationAction: vi.fn(),
}));

const mockSignOut = vi.fn();

function mockAuth(overrides: Record<string, unknown> = {}) {
vi.mocked(useAuth).mockReturnValue({
impersonator: { email: 'admin@example.com', reason: 'testing' },
user: { id: '123', email: 'user@example.com' },
organizationId: undefined,
signOut: mockSignOut,
...overrides,
} as any);
}

describe('Impersonation', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should return null if not impersonating', () => {
mockAuth({ impersonator: undefined });
const { container } = render(<Impersonation />);
expect(container).toBeEmptyDOMElement();
});

it('should return null if user is not present', () => {
mockAuth({ user: null });
const { container } = render(<Impersonation />);
expect(container).toBeEmptyDOMElement();
});

it('should render impersonation banner when impersonating', () => {
mockAuth();
const { container } = render(<Impersonation />);
expect(container.querySelector('[data-workos-impersonation-root]')).toBeInTheDocument();
});

it('should display user email in banner', () => {
mockAuth();
render(<Impersonation />);
expect(screen.getByText('user@example.com')).toBeInTheDocument();
});

it('should render with organization info when organizationId is provided', async () => {
mockAuth({ organizationId: 'org_123' });
vi.mocked(getOrganizationAction).mockResolvedValue({ id: 'org_123', name: 'Test Org' });

await act(async () => {
render(<Impersonation />);
});

expect(getOrganizationAction).toHaveBeenCalledWith({ data: 'org_123' });
});

it('should render at the bottom by default', () => {
mockAuth();
const { container } = render(<Impersonation />);
const banner = container.querySelector('[data-workos-impersonation-root] > div:nth-child(2)') as HTMLElement;
expect(banner.style.bottom).toBe('var(--wi-s)');
expect(banner.style.top).toBe('');
});

it('should render at the top when side prop is "top"', () => {
mockAuth();
const { container } = render(<Impersonation side="top" />);
const banner = container.querySelector('[data-workos-impersonation-root] > div:nth-child(2)') as HTMLElement;
expect(banner.style.top).toBe('var(--wi-s)');
expect(banner.style.bottom).toBe('');
});

it('should merge custom styles with default styles', () => {
mockAuth();
const { container } = render(<Impersonation style={{ backgroundColor: 'red' }} />);
const root = container.querySelector('[data-workos-impersonation-root]') as HTMLElement;
expect(root.style.backgroundColor).toBe('red');
});

it('should call signOut when the Stop button is clicked', async () => {
mockAuth();
render(<Impersonation />);
await act(async () => {
(await screen.findByText('Stop')).click();
});
expect(mockSignOut).toHaveBeenCalledWith({ returnTo: undefined });
});

it('should pass returnTo prop to signOut when provided', async () => {
mockAuth();
render(<Impersonation returnTo="/dashboard" />);
await act(async () => {
(await screen.findByText('Stop')).click();
});
expect(mockSignOut).toHaveBeenCalledWith({ returnTo: '/dashboard' });
});

it('should not call getOrganizationAction when organizationId is not provided', () => {
mockAuth();
render(<Impersonation />);
expect(getOrganizationAction).not.toHaveBeenCalled();
});

it('should not call getOrganizationAction when impersonator is not present', () => {
mockAuth({ impersonator: undefined, organizationId: 'org_123' });
render(<Impersonation />);
expect(getOrganizationAction).not.toHaveBeenCalled();
});

it('should not call getOrganizationAction when user is not present', () => {
mockAuth({ user: null, organizationId: 'org_123' });
render(<Impersonation />);
expect(getOrganizationAction).not.toHaveBeenCalled();
});

it('should not call getOrganizationAction again when organization is already loaded with same ID', async () => {
vi.mocked(getOrganizationAction).mockResolvedValue({ id: 'org_123', name: 'Test Org' });
mockAuth({ organizationId: 'org_123' });

const { rerender } = await act(async () => render(<Impersonation />));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});

expect(getOrganizationAction).toHaveBeenCalledTimes(1);

await act(async () => rerender(<Impersonation />));
expect(getOrganizationAction).toHaveBeenCalledTimes(1);
});

it('should call getOrganizationAction again when organizationId changes', async () => {
vi.mocked(getOrganizationAction)
.mockResolvedValueOnce({ id: 'org_123', name: 'Test Org 1' })
.mockResolvedValueOnce({ id: 'org_456', name: 'Test Org 2' });

mockAuth({ organizationId: 'org_123' });

const { rerender } = await act(async () => render(<Impersonation />));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});

expect(getOrganizationAction).toHaveBeenCalledTimes(1);
expect(getOrganizationAction).toHaveBeenCalledWith({ data: 'org_123' });

await act(async () => {
mockAuth({ organizationId: 'org_456' });
rerender(<Impersonation />);
});

expect(getOrganizationAction).toHaveBeenCalledTimes(2);
expect(getOrganizationAction).toHaveBeenCalledWith({ data: 'org_456' });
});
});
Loading
Loading