Skip to content

Commit 29f38f2

Browse files
author
MargeBot
committed
Merge branch 'B2B-scim-setup-users-modal-ui' into 'main'
SCIM setup modal component only users See merge request web/clients!25508
2 parents 2a30cc0 + 17e3dff commit 29f38f2

2 files changed

Lines changed: 368 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
@keyframes scim-spin {
2+
from {
3+
transform: rotate(0deg);
4+
}
5+
6+
to {
7+
transform: rotate(360deg);
8+
}
9+
}
10+
11+
.scim-spin {
12+
animation: scim-spin 1s linear infinite;
13+
}
14+
15+
.scim-group-meta {
16+
.new-member::before {
17+
content: '';
18+
margin-inline: 0.5em;
19+
}
20+
}
21+
22+
.scim-setup-list {
23+
max-block-size: min(480px, 40vh);
24+
overflow: hidden auto;
25+
26+
// Push the scrollbar towards the modal edge while keeping the content aligned
27+
margin-inline-end: calc(var(--margin) * -0.75);
28+
padding-inline-end: calc(var(--margin) * 0.75);
29+
}
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
import { type ReactNode, useState } from 'react';
2+
3+
import { c, msgid } from 'ttag';
4+
5+
import { Avatar } from '@proton/atoms/Avatar/Avatar';
6+
import { Button } from '@proton/atoms/Button/Button';
7+
import Icon from '@proton/components/components/icon/Icon';
8+
import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
9+
import ModalTwo from '@proton/components/components/modalTwo/Modal';
10+
import ModalTwoContent from '@proton/components/components/modalTwo/ModalContent';
11+
import ModalTwoFooter from '@proton/components/components/modalTwo/ModalFooter';
12+
import ModalTwoHeader from '@proton/components/components/modalTwo/ModalHeader';
13+
import { IcArrowsRotate } from '@proton/icons/icons/IcArrowsRotate';
14+
import { IcCheckmarkCircleFilled } from '@proton/icons/icons/IcCheckmarkCircleFilled';
15+
import { IcInfoCircle } from '@proton/icons/icons/IcInfoCircle';
16+
import { IcKey } from '@proton/icons/icons/IcKey';
17+
import { IcLink } from '@proton/icons/icons/IcLink';
18+
import { BRAND_NAME, MEMBER_PRIVATE } from '@proton/shared/lib/constants';
19+
import { getInitials } from '@proton/shared/lib/helpers/string';
20+
import type { Group, MemberReadyForManualUnprivatization } from '@proton/shared/lib/interfaces';
21+
import type { GroupMember } from '@proton/shared/lib/interfaces/GroupMember';
22+
import { useFlag } from '@proton/unleash/useFlag';
23+
24+
import './ScimSetupModal.scss';
25+
26+
enum ItemStatus {
27+
Waiting = 'waiting',
28+
Finalizing = 'finalizing',
29+
Completed = 'completed',
30+
Unknown = 'unknown',
31+
}
32+
33+
enum Phase {
34+
Idle = 'idle',
35+
Working = 'working',
36+
Done = 'done',
37+
}
38+
39+
interface PendingUserItem {
40+
member: MemberReadyForManualUnprivatization;
41+
status: ItemStatus;
42+
}
43+
44+
interface PendingGroupMemberItem {
45+
member: GroupMember;
46+
status: ItemStatus;
47+
}
48+
49+
interface PendingGroupItem {
50+
group: Group;
51+
members: PendingGroupMemberItem[];
52+
status: ItemStatus;
53+
}
54+
55+
interface Props extends ModalProps {
56+
users: PendingUserItem[];
57+
groups: PendingGroupItem[];
58+
phase: Phase;
59+
onFinish: () => void;
60+
}
61+
62+
const StatusBadge = ({ status }: { status: ItemStatus }) => {
63+
switch (status) {
64+
case ItemStatus.Waiting:
65+
return <span className="text-sm color-hint text-semibold">{c('Status').t`... waiting`}</span>;
66+
case ItemStatus.Finalizing:
67+
return (
68+
<span className="inline-flex items-center gap-1 text-semibold color-primary">
69+
<IcArrowsRotate className="scim-spin" />
70+
{c('Status').t`finalizing`}
71+
</span>
72+
);
73+
case ItemStatus.Completed:
74+
return (
75+
<span className="inline-flex items-center gap-1 text-sm color-success">
76+
<IcCheckmarkCircleFilled />
77+
{c('Status').t`completed`}
78+
</span>
79+
);
80+
case ItemStatus.Unknown:
81+
default:
82+
return null;
83+
}
84+
};
85+
86+
const UserInfoRow = ({
87+
name,
88+
email,
89+
status,
90+
isPrivate,
91+
}: {
92+
name: string;
93+
email?: string;
94+
status: ItemStatus;
95+
isPrivate?: boolean;
96+
}) => (
97+
<div className="flex items-center gap-3 py-3">
98+
<Avatar className="shrink-0 text-rg" color="weak">
99+
{getInitials(name)}
100+
</Avatar>
101+
<div className="flex-1 min-w-0">
102+
<span className="flex items-center gap-1">
103+
<span className="m-0 text-ellipsis" title={name}>
104+
{name}
105+
</span>
106+
{isPrivate && <IcKey className="shrink-0 color-weak" alt={c('scim').t`Private user`} />}
107+
</span>
108+
{email && (
109+
<p className="m-0 text-sm color-weak text-ellipsis" title={email}>
110+
{email}
111+
</p>
112+
)}
113+
</div>
114+
<StatusBadge status={status} />
115+
</div>
116+
);
117+
118+
const Section = ({
119+
label,
120+
sectionStatus,
121+
expanded,
122+
onToggle,
123+
children,
124+
}: {
125+
label: string;
126+
sectionStatus: ItemStatus;
127+
expanded: boolean;
128+
onToggle: () => void;
129+
children: ReactNode;
130+
}) => {
131+
return (
132+
<div className="border-bottom">
133+
<button
134+
type="button"
135+
className="w-full flex items-center justify-space-between py-3 text-left"
136+
onClick={onToggle}
137+
>
138+
<span className="block">
139+
<span className="block text-bold">{label}</span>
140+
<span className="block mt-1 text-sm color-weak">{c('scim').t`From your identity provider`}</span>
141+
</span>
142+
<span className="flex items-center gap-2">
143+
{!expanded && <StatusBadge status={sectionStatus} />}
144+
<Icon name={expanded ? 'chevron-up' : 'chevron-down'} className="shrink-0" />
145+
</span>
146+
</button>
147+
{expanded && <div className="pb-3">{children}</div>}
148+
</div>
149+
);
150+
};
151+
152+
const GroupInfoRow = ({
153+
group,
154+
members,
155+
status,
156+
expanded,
157+
onToggle,
158+
}: PendingGroupItem & {
159+
expanded: boolean;
160+
onToggle: () => void;
161+
}) => {
162+
const memberCount = members.length;
163+
const hasMembers = memberCount > 0;
164+
165+
const header = (
166+
<>
167+
<span
168+
className="rounded flex items-center justify-center shrink-0 w-custom h-custom"
169+
style={{
170+
'--w-custom': '2rem',
171+
'--h-custom': '2rem',
172+
backgroundColor: 'var(--interaction-norm-minor-1)',
173+
}}
174+
>
175+
<IcLink className="m-auto color-primary shrink-0" size={4} />
176+
</span>
177+
<span className="block flex-1 min-w-0">
178+
<span className="block text-ellipsis text-bold" title={group.Name}>
179+
{group.Name}
180+
</span>
181+
<span className="scim-group-meta block text-sm color-weak">
182+
{/* translator: full sentence will be "Updated/New • {number} new member/s" */}
183+
<span>{!group.Address.HasKeys ? c('scim').t`New` : c('scim').t`Updated`}</span>
184+
<span className="new-member">
185+
{c('scim').ngettext(
186+
msgid`${memberCount} new member`,
187+
`${memberCount} new members`,
188+
memberCount
189+
)}
190+
</span>
191+
</span>
192+
</span>
193+
<span className="flex items-center gap-2">
194+
{<StatusBadge status={status} />}
195+
{hasMembers && <Icon name={expanded ? 'chevron-up' : 'chevron-down'} className="shrink-0" />}
196+
</span>
197+
</>
198+
);
199+
200+
return (
201+
<div>
202+
{hasMembers ? (
203+
<button type="button" className="w-full flex items-center gap-3 py-3 text-left" onClick={onToggle}>
204+
{header}
205+
</button>
206+
) : (
207+
<div className="flex items-center gap-3 py-3">{header}</div>
208+
)}
209+
{expanded && hasMembers && (
210+
<div className="pl-11">
211+
{members.map(({ member, status: memberStatus }) => (
212+
<UserInfoRow key={member.ID} name={member.Email ?? ''} status={memberStatus} />
213+
))}
214+
</div>
215+
)}
216+
</div>
217+
);
218+
};
219+
220+
const getSectionStatus = (statuses: ItemStatus[]): ItemStatus => {
221+
if (statuses.some((status) => status === ItemStatus.Finalizing)) {
222+
return ItemStatus.Finalizing;
223+
}
224+
225+
if (statuses.some((status) => status === ItemStatus.Waiting)) {
226+
return ItemStatus.Waiting;
227+
}
228+
229+
if (statuses.every((status) => status === ItemStatus.Completed)) {
230+
return ItemStatus.Completed;
231+
}
232+
233+
return ItemStatus.Unknown;
234+
};
235+
236+
const ScimSetupModal = ({ users, groups, phase, onFinish, onClose, ...rest }: Props) => {
237+
const isUserGroupsScimGroupsEnabled = useFlag('UserGroupsScimGroups');
238+
239+
const [usersExpanded, setUsersExpanded] = useState(false);
240+
const [groupsExpanded, setGroupsExpanded] = useState(false);
241+
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
242+
243+
const numberOfPendingUsers = users.length;
244+
const numberOfPendingGroups = groups.length;
245+
246+
if (!isUserGroupsScimGroupsEnabled || (!numberOfPendingUsers && !numberOfPendingGroups)) {
247+
return null;
248+
}
249+
250+
return (
251+
<ModalTwo {...rest} onClose={onClose} size="large">
252+
<ModalTwoHeader
253+
title={c('Title').t`Approve changes`}
254+
additionalContent={
255+
<p className="mt-0 mb-1 color-weak">
256+
{c('scim').t`Once approved, they can access all ${BRAND_NAME} services and share securely.`}
257+
</p>
258+
}
259+
/>
260+
<ModalTwoContent>
261+
<p className="mb-4 text-md">{c('scim').t`Review users and groups before finishing setup`}</p>
262+
263+
<div className="scim-setup-list">
264+
{numberOfPendingUsers > 0 && (
265+
<Section
266+
label={c('scim').ngettext(
267+
msgid`${numberOfPendingUsers} user synced`,
268+
`${numberOfPendingUsers} users synced`,
269+
numberOfPendingUsers
270+
)}
271+
sectionStatus={getSectionStatus(users.map((user) => user.status))}
272+
expanded={usersExpanded}
273+
onToggle={() => setUsersExpanded(!usersExpanded)}
274+
>
275+
{users.map(({ member, status }) => (
276+
<UserInfoRow
277+
key={member.ID}
278+
name={member.Name}
279+
email={member.Addresses?.[0]?.Email}
280+
status={status}
281+
isPrivate={member.Private === MEMBER_PRIVATE.UNREADABLE}
282+
/>
283+
))}
284+
</Section>
285+
)}
286+
287+
{numberOfPendingGroups > 0 && (
288+
<Section
289+
label={c('scim').ngettext(
290+
msgid`${numberOfPendingGroups} group synced`,
291+
`${numberOfPendingGroups} groups synced`,
292+
numberOfPendingGroups
293+
)}
294+
sectionStatus={getSectionStatus(groups.map((group) => group.status))}
295+
expanded={groupsExpanded}
296+
onToggle={() => setGroupsExpanded((isGroupsExpanded) => !isGroupsExpanded)}
297+
>
298+
{groups.map((groupItem) => (
299+
<GroupInfoRow
300+
key={groupItem.group.ID}
301+
{...groupItem}
302+
expanded={!!expandedGroups[groupItem.group.ID]}
303+
onToggle={() =>
304+
setExpandedGroups((prev) => ({
305+
...prev,
306+
[groupItem.group.ID]: !prev[groupItem.group.ID],
307+
}))
308+
}
309+
/>
310+
))}
311+
</Section>
312+
)}
313+
</div>
314+
315+
<div className="color-hint mt-4 flex flex-nowrap gap-2 items-start">
316+
<IcInfoCircle className="shrink-0 mt-1" />
317+
<span>
318+
{c('scim')
319+
.t`Users that you have set to private need to accept the invite to finish setup. We'll send them an invite once you approve.`}
320+
</span>
321+
</div>
322+
</ModalTwoContent>
323+
<ModalTwoFooter>
324+
<Button onClick={onClose} disabled={phase === Phase.Working}>
325+
{c('Action').t`Cancel`}
326+
</Button>
327+
<Button
328+
color="norm"
329+
loading={phase === Phase.Working}
330+
onClick={phase === Phase.Done ? onClose : onFinish}
331+
>
332+
{phase === Phase.Done ? c('Action').t`Close` : c('Action').t`Finish`}
333+
</Button>
334+
</ModalTwoFooter>
335+
</ModalTwo>
336+
);
337+
};
338+
339+
export default ScimSetupModal;

0 commit comments

Comments
 (0)