Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* Organization member detail component.
* @module organization-member-detail
*/

import { getComponentStyles } from '@auth0/universal-components-core';
import { ArrowLeft } from 'lucide-react';
import * as React from 'react';

import { GateKeeper } from '../shared/gate-keeper/gate-keeper';

import { MemberDeleteModal } from '@/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-delete-modal';
import { MemberRemoveFromOrgModal } from '@/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-remove-from-org-modal';
import { OrganizationMemberEditDetailsTab } from '@/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-details-tab';
import { OrganizationMemberEditRolesTab } from '@/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-roles-tab';
import { StyledScope } from '@/components/auth0/shared/styled-scope';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useOrganizationMemberDetail } from '@/hooks/my-organization/use-member-detail';
import { useTheme } from '@/hooks/shared/use-theme';
import { useTranslator } from '@/hooks/shared/use-translator';
import type {
OrganizationMemberDetailProps,
OrganizationMemberDetailViewProps,
} from '@/types/my-organization/member-management/organization-member-detail-types';

export type { OrganizationMemberDetailViewProps };

/**
* Returns the initials (up to 2 chars) from a display name.
* @param name - The display name to extract initials from
* @returns Up to 2 uppercase initials, or '?' if the name is empty
*/
function getInitials(name?: string): string {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
const first = parts[0] ?? '';
if (parts.length === 1) return first.charAt(0).toUpperCase();
const last = parts[parts.length - 1] ?? '';
return (first.charAt(0) + last.charAt(0)).toUpperCase();
}

type HeaderProps = Pick<
OrganizationMemberDetailViewProps,
'member' | 'styling' | 'customMessages' | 'handleBack'
>;

/**
* Member detail header component
* @param root0 - Component props containing state and handlers
* @returns The rendered header element
*/
function Header({ member, styling, customMessages, handleBack }: HeaderProps): React.JSX.Element {
const { isDarkMode } = useTheme();
const { t } = useTranslator('member_management', customMessages as Record<string, unknown>);
const currentStyles = React.useMemo(
() => getComponentStyles(styling, isDarkMode),
[styling, isDarkMode],
);

const memberRecord = member as Record<string, unknown> | null;
const userId = (memberRecord?.user_id as string | undefined) ?? '';
const displayName = (memberRecord?.name as string | undefined) ?? userId;
const initials = getInitials(displayName || undefined);

return (
<div className={currentStyles.classes?.['OrganizationMemberDetail-header']}>
<Button
variant="ghost"
size="sm"
className="mb-4 -ml-2 text-muted-foreground hover:text-primary"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4 mr-1" />
{t('member.detail.back_button')}
</Button>

<div className="flex items-center gap-4 mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-muted-foreground text-xl font-semibold shrink-0">
{initials}
</div>
<div className="flex flex-col gap-1 min-w-0">
<h1 className="text-2xl font-bold text-primary truncate">{displayName}</h1>
{userId && (
<Badge variant="secondary" className="w-fit font-mono text-xs">
{userId}
</Badge>
)}
</div>
</div>
</div>
);
}

/**
* View component for organization member detail.
* @param props - Component props containing state and handlers
* @returns The rendered member detail view element
*/
export function OrganizationMemberDetailView(
props: OrganizationMemberDetailViewProps,
): React.JSX.Element {
const {
styling,
customMessages,
activeTab,
showRemoveFromOrgModal,
isRemovingFromOrg,
showDeleteMemberModal,
isDeletingMember,
setActiveTab,
handleRemoveFromOrgCancel,
handleRemoveFromOrgConfirm,
handleDeleteMemberCancel,
handleDeleteMemberConfirm,
} = props;

const { isDarkMode } = useTheme();
const { t } = useTranslator('member_management', customMessages as Record<string, unknown>);

const currentStyles = React.useMemo(
() => getComponentStyles(styling, isDarkMode),
[styling, isDarkMode],
);

return (
<StyledScope style={currentStyles.variables}>
<div className={currentStyles.classes?.['OrganizationMemberDetail-root']}>
<Header
member={props.member}
styling={styling}
customMessages={customMessages}
handleBack={props.handleBack}
/>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'details' | 'roles')}
className={currentStyles.classes?.['OrganizationMemberDetail-tabs']}
>
<TabsList>
<TabsTrigger value="details">{t('member.detail.tabs.details')}</TabsTrigger>
<TabsTrigger value="roles">{t('member.detail.tabs.roles')}</TabsTrigger>
</TabsList>

<TabsContent
value="details"
className={currentStyles.classes?.['OrganizationMemberDetail-detailsTab']}
>
<OrganizationMemberEditDetailsTab
member={props.member}
customMessages={customMessages}
isRemovingFromOrg={isRemovingFromOrg}
handleRemoveFromOrgClick={props.handleRemoveFromOrgClick}
/>
</TabsContent>

<TabsContent
value="roles"
className={currentStyles.classes?.['OrganizationMemberDetail-rolesTab']}
>
<OrganizationMemberEditRolesTab
customMessages={customMessages}
memberRoles={props.memberRoles}
availableRoles={props.availableRoles}
isFetchingRoles={props.isFetchingRoles}
removingRoleId={props.removingRoleId}
showAssignRolesModal={props.showAssignRolesModal}
isAssigningRole={props.isAssigningRole}
showRemoveRoleModal={props.showRemoveRoleModal}
roleToRemove={props.roleToRemove}
handleAssignRolesClick={props.handleAssignRolesClick}
handleAssignRolesCancel={props.handleAssignRolesCancel}
handleAssignRolesSubmit={props.handleAssignRolesSubmit}
handleRemoveRoleClick={props.handleRemoveRoleClick}
handleRemoveRoleCancel={props.handleRemoveRoleCancel}
handleRemoveRoleConfirm={props.handleRemoveRoleConfirm}
/>
</TabsContent>
</Tabs>

<MemberRemoveFromOrgModal
isOpen={showRemoveFromOrgModal}
isLoading={isRemovingFromOrg}
customMessages={customMessages}
onClose={handleRemoveFromOrgCancel}
onConfirm={handleRemoveFromOrgConfirm}
/>

<MemberDeleteModal
isOpen={showDeleteMemberModal}
isLoading={isDeletingMember}
customMessages={customMessages}
onClose={handleDeleteMemberCancel}
onConfirm={handleDeleteMemberConfirm}
/>
</div>
</StyledScope>
);
}

/**
* Container component for organization member detail.
* @param props - {@link OrganizationMemberDetailProps}
* @returns The rendered member detail container element
*/
export function OrganizationMemberDetail(props: OrganizationMemberDetailProps) {
const {
userId,
onBack,
customMessages = {},
styling = { variables: { common: {}, light: {}, dark: {} }, classes: {} },
removeFromOrgAction,
deleteMemberAction,
assignRoleAction,
removeRoleAction,
} = props;

const memberDetail = useOrganizationMemberDetail({
userId,
onBack,
customMessages,
removeFromOrgAction,
deleteMemberAction,
assignRoleAction,
removeRoleAction,
});

return (
<GateKeeper isLoading={memberDetail.isLoading} styling={styling}>
<OrganizationMemberDetailView
{...memberDetail}
styling={styling}
customMessages={customMessages}
/>
</GateKeeper>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
*/

import type { OrgMember } from '@auth0/universal-components-core';
import { Copy } from 'lucide-react';
import * as React from 'react';

import { CopyableTextField } from '@/components/auth0/shared/copyable-text-field';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useTranslator } from '@/hooks/shared/use-translator';
import type { OrganizationMemberDetailMessages } from '@/types/my-organization/member-management/organization-member-detail-types';

Expand All @@ -33,6 +35,60 @@ function formatDate(dateStr?: string): string {
});
}

/**
* Displays a value with a copy-to-clipboard button.
* @param root0 - Component props
* @param root0.value - The string value to display and copy
* @param root0.copyLabel - Label for the copy button tooltip
* @param root0.copiedLabel - Label shown after copying
* @returns The rendered copyable value element
*/
function CopyableValue({
value,
copyLabel,
copiedLabel,
}: {
value: string;
copyLabel: string;
copiedLabel: string;
}): React.JSX.Element {
const [tooltipOpen, setTooltipOpen] = React.useState(false);
const [tooltipText, setTooltipText] = React.useState(copyLabel);

const handleCopy = async () => {
await navigator.clipboard.writeText(value);
setTooltipText(copiedLabel);
setTooltipOpen(true);
setTimeout(() => {
setTooltipText(copyLabel);
setTooltipOpen(false);
}, 1000);
};

return (
<div className="flex items-center gap-1">
<span className="text-sm text-primary">{value}</span>
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleCopy}
aria-label={copyLabel}
>
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="end" sideOffset={5}>
{tooltipText}
</TooltipContent>
</Tooltip>
</div>
);
}

/**
* Renders the user details card for a member showing name, email, phone, and login timestamps.
* @param root0 - Component props
Expand Down Expand Up @@ -82,22 +138,28 @@ export function MemberDetailUserDetails({
];

return (
<Card className="p-6">
<>
<h3 className="text-base font-semibold text-primary mb-4">
{t('member.detail.user_details.title')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
{fields.map((field) => (
<div key={field.label} className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">{field.label}</span>
{field.copyable && field.value !== '—' ? (
<CopyableTextField value={field.value} className="h-8 text-sm" />
) : (
<span className="text-sm text-primary">{field.value}</span>
)}
</div>
))}
</div>
</Card>
<Card className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
{fields.map((field) => (
<div key={field.label} className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground shrink-0">{field.label}</span>
{field.copyable && field.value !== '—' ? (
<CopyableValue
value={field.value}
copyLabel={t('copy')}
copiedLabel={t('copied')}
/>
) : (
<span className="text-sm text-primary">{field.value}</span>
)}
</div>
))}
</div>
</Card>
</>
);
}
Loading