@startsimpli/ui 0.4.83 → 0.4.85
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/package.json +1 -1
- package/src/components/index.ts +2 -4
- package/src/components/navigation/__tests__/entity-switcher.test.tsx +258 -0
- package/src/components/navigation/__tests__/switcher-slot.test.tsx +102 -0
- package/src/components/navigation/app-shell.tsx +8 -0
- package/src/components/navigation/entity-switcher.tsx +293 -0
- package/src/components/navigation/index.ts +6 -0
- package/src/components/navigation/sidebar.tsx +13 -0
- package/src/components/team/MembersSection.tsx +7 -0
- package/src/components/team/MembersTable.tsx +38 -3
- package/src/components/team/__tests__/member-detail.test.tsx +223 -0
- package/src/components/team/members-table-default-class-names.ts +3 -0
- package/src/components/team/pages/MemberDetailPanel.tsx +221 -0
- package/src/components/team/pages/index.ts +10 -0
- package/src/components/team/pages/member-detail-panel-default-class-names.ts +68 -0
- package/src/components/team/types.ts +10 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MemberDetailPanel — "what has this person done" (startsim-768w.19.6).
|
|
5
|
+
*
|
|
6
|
+
* THE PAGE-LEVEL COMPOSER for one member, beside TeamSettingsPage /
|
|
7
|
+
* DomainsSettingsPage (rule #9): identity header, the facts row (last login /
|
|
8
|
+
* activated / invited by / role last changed), the invite state with a Resend
|
|
9
|
+
* affordance while it is pending or expired, and that person's own activity.
|
|
10
|
+
* An app supplies a member row, an actor-scoped activity client and its theme
|
|
11
|
+
* classNames — nothing else.
|
|
12
|
+
*
|
|
13
|
+
* THE DESIGN, in one line: a member's activity is NOT a new feed. It is THE one
|
|
14
|
+
* permission-filtered event stream narrowed by actor, so this panel embeds the
|
|
15
|
+
* shared `ActivityFeedPanel` with a client the host already scoped
|
|
16
|
+
* (`(p) => api.foundry.activity(slug, { ...p, actor: userId })`). There is no
|
|
17
|
+
* second timeline, no second client, and no level check anywhere below this
|
|
18
|
+
* line — visibility was decided by the endpoint, and a row that arrives is
|
|
19
|
+
* rendered. Registering a new event type needs zero changes here.
|
|
20
|
+
*
|
|
21
|
+
* CLIENT-INJECTED, like every other composer in this package, which is what
|
|
22
|
+
* keeps @startsimpli/api out of packages/ui.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as React from 'react'
|
|
26
|
+
import { ActivityFeedPanel } from '../../activity/ActivityFeedPanel'
|
|
27
|
+
import type { ActivityClient } from '../../activity/types'
|
|
28
|
+
import { userDisplayName, userInitials, type MemberRow, type TeamRole } from '../types'
|
|
29
|
+
import {
|
|
30
|
+
MEMBER_DETAIL_PANEL_DEFAULTS,
|
|
31
|
+
type MemberDetailPanelClassNames,
|
|
32
|
+
} from './member-detail-panel-default-class-names'
|
|
33
|
+
|
|
34
|
+
export interface MemberDetailPanelProps {
|
|
35
|
+
/** The member's facts, as the roster/detail endpoint reported them. */
|
|
36
|
+
member: MemberRow
|
|
37
|
+
/**
|
|
38
|
+
* THE activity stream, already narrowed to this actor by the host. The panel
|
|
39
|
+
* neither adds nor removes rows — the endpoint decided what this viewer sees.
|
|
40
|
+
*/
|
|
41
|
+
activityClient: ActivityClient
|
|
42
|
+
/** Cache namespace, e.g. `['foundry', slug, 'member', userId, 'activity']`. */
|
|
43
|
+
activityQueryKey?: readonly unknown[]
|
|
44
|
+
/** Expose management affordances (currently: resend invite). */
|
|
45
|
+
canManage?: boolean
|
|
46
|
+
/** Re-send the set-password link. Offered on pending/expired members only. */
|
|
47
|
+
onResendInvite?: (userId: string) => void | Promise<void>
|
|
48
|
+
/** Disable the resend button while a request is in flight. */
|
|
49
|
+
isResending?: boolean
|
|
50
|
+
/** Failure line under the invite row — e.g. "they already set a password". */
|
|
51
|
+
error?: React.ReactNode
|
|
52
|
+
/** Copy for the embedded feed, so a host can name the surface. */
|
|
53
|
+
activityTitle?: React.ReactNode
|
|
54
|
+
activityDescription?: React.ReactNode
|
|
55
|
+
activityEmptyTitle?: string
|
|
56
|
+
activityEmptyDescription?: string
|
|
57
|
+
/** Poll interval for the embedded feed. Off by default. */
|
|
58
|
+
activityRefetchInterval?: number | false
|
|
59
|
+
classNames?: MemberDetailPanelClassNames
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ROLE_LABEL: Record<TeamRole, string> = {
|
|
63
|
+
owner: 'Owner',
|
|
64
|
+
admin: 'Admin',
|
|
65
|
+
member: 'Member',
|
|
66
|
+
viewer: 'Viewer',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type RowStatus = 'active' | 'pending' | 'expired' | 'deactivated'
|
|
70
|
+
|
|
71
|
+
const STATUS_LABEL: Record<RowStatus, string> = {
|
|
72
|
+
active: 'Active',
|
|
73
|
+
pending: 'Invite pending',
|
|
74
|
+
expired: 'Invite expired',
|
|
75
|
+
deactivated: 'Deactivated',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A deactivated membership outranks whatever the invite says — it is the
|
|
79
|
+
* reason this person has no access right now. */
|
|
80
|
+
function rowStatus(m: MemberRow): RowStatus {
|
|
81
|
+
if (m.isActive === false) return 'deactivated'
|
|
82
|
+
return m.inviteStatus ?? 'active'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Absolute date + time, not "3 days ago": an owner deciding whether to resend
|
|
86
|
+
* an invite or chase a dormant account wants the actual moment. */
|
|
87
|
+
function formatWhen(iso?: string | null): string | null {
|
|
88
|
+
if (!iso) return null
|
|
89
|
+
const d = new Date(iso)
|
|
90
|
+
if (Number.isNaN(d.getTime())) return iso
|
|
91
|
+
return d.toLocaleString(undefined, {
|
|
92
|
+
month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit',
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function MemberDetailPanel({
|
|
97
|
+
member,
|
|
98
|
+
activityClient,
|
|
99
|
+
activityQueryKey = ['member-activity'],
|
|
100
|
+
canManage = false,
|
|
101
|
+
onResendInvite,
|
|
102
|
+
isResending = false,
|
|
103
|
+
error,
|
|
104
|
+
activityTitle = 'Activity',
|
|
105
|
+
activityDescription = 'What this person has done here, newest first.',
|
|
106
|
+
activityEmptyTitle = 'Nothing recorded yet',
|
|
107
|
+
activityEmptyDescription = 'Actions this person takes here will show up in this list.',
|
|
108
|
+
activityRefetchInterval = false,
|
|
109
|
+
classNames,
|
|
110
|
+
}: MemberDetailPanelProps) {
|
|
111
|
+
const cls = { ...MEMBER_DETAIL_PANEL_DEFAULTS, ...(classNames ?? {}) }
|
|
112
|
+
const status = rowStatus(member)
|
|
113
|
+
const statusClass: Record<RowStatus, string> = {
|
|
114
|
+
active: cls.statusActive,
|
|
115
|
+
pending: cls.statusPending,
|
|
116
|
+
expired: cls.statusExpired,
|
|
117
|
+
deactivated: cls.statusDeactivated,
|
|
118
|
+
}
|
|
119
|
+
const displayName = userDisplayName(member.user) || member.userId
|
|
120
|
+
const secondary =
|
|
121
|
+
member.user?.email && member.user.email !== displayName ? member.user.email : null
|
|
122
|
+
const canResend =
|
|
123
|
+
canManage && !!onResendInvite && (status === 'pending' || status === 'expired')
|
|
124
|
+
|
|
125
|
+
/** One labelled fact. An absent value renders an explicit sentence rather
|
|
126
|
+
* than a blank: "never signed in" is an ANSWER, and the whole complaint this
|
|
127
|
+
* panel exists for was surfaces that said nothing at all. */
|
|
128
|
+
const fact = (testId: string, label: string, value: string | null, empty: string) => (
|
|
129
|
+
<div className={cls.fact} data-testid={testId}>
|
|
130
|
+
<p className={cls.factLabel}>{label}</p>
|
|
131
|
+
<p className={value ? cls.factValue : cls.factEmpty} title={value ?? empty}>
|
|
132
|
+
{value ?? empty}
|
|
133
|
+
</p>
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className={cls.root} data-testid="member-detail-panel">
|
|
139
|
+
<div className={cls.header}>
|
|
140
|
+
<div className={cls.identity}>
|
|
141
|
+
{member.user?.avatarUrl ? (
|
|
142
|
+
<img
|
|
143
|
+
src={member.user.avatarUrl}
|
|
144
|
+
alt=""
|
|
145
|
+
className={cls.avatar}
|
|
146
|
+
style={{ objectFit: 'cover' }}
|
|
147
|
+
/>
|
|
148
|
+
) : (
|
|
149
|
+
<span className={cls.avatar} aria-hidden="true">{userInitials(member.user)}</span>
|
|
150
|
+
)}
|
|
151
|
+
<div className="flex min-w-0 flex-col">
|
|
152
|
+
<h2 className={cls.name}>{displayName}</h2>
|
|
153
|
+
{secondary && <span className={cls.email}>{secondary}</span>}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
<div className={cls.badges}>
|
|
157
|
+
<span className={cls.roleBadge}>{ROLE_LABEL[member.role]}</span>
|
|
158
|
+
<span
|
|
159
|
+
className={`${cls.statusBadge} ${statusClass[status]}`.trim()}
|
|
160
|
+
data-testid="member-invite-status"
|
|
161
|
+
data-invite-status={status}
|
|
162
|
+
>
|
|
163
|
+
{STATUS_LABEL[status]}
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className={cls.facts} data-testid="member-facts">
|
|
169
|
+
{fact('fact-last-login', 'Last signed in',
|
|
170
|
+
formatWhen(member.lastLoginAt), 'Never signed in')}
|
|
171
|
+
{fact('fact-activated', 'Joined',
|
|
172
|
+
formatWhen(member.activatedAt ?? member.joinedAt), 'Not yet')}
|
|
173
|
+
{fact('fact-invited-by', 'Invited by', member.invitedByEmail ?? null, 'No record')}
|
|
174
|
+
{fact('fact-role-changed', 'Role last changed',
|
|
175
|
+
formatWhen(member.roleChangedAt), 'Never changed')}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{(status === 'pending' || status === 'expired') && (
|
|
179
|
+
<div className={cls.inviteRow}>
|
|
180
|
+
<span className={cls.inviteNote}>
|
|
181
|
+
{status === 'expired'
|
|
182
|
+
? `This invite expired${
|
|
183
|
+
formatWhen(member.inviteExpiresAt) ? ` on ${formatWhen(member.inviteExpiresAt)}` : ''
|
|
184
|
+
} — they can no longer use the link they were sent.`
|
|
185
|
+
: `Invite sent${
|
|
186
|
+
formatWhen(member.inviteExpiresAt) ? `, expires ${formatWhen(member.inviteExpiresAt)}` : ''
|
|
187
|
+
} — they haven't set a password yet.`}
|
|
188
|
+
</span>
|
|
189
|
+
{canResend && (
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
className={cls.resendButton}
|
|
193
|
+
disabled={isResending}
|
|
194
|
+
onClick={() => onResendInvite!(member.userId)}
|
|
195
|
+
>
|
|
196
|
+
{isResending ? 'Sending…' : 'Resend invite'}
|
|
197
|
+
</button>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{error && <p className={cls.inviteNote}>{error}</p>}
|
|
203
|
+
|
|
204
|
+
<div className={cls.activity}>
|
|
205
|
+
<ActivityFeedPanel
|
|
206
|
+
client={activityClient}
|
|
207
|
+
queryKey={activityQueryKey}
|
|
208
|
+
refetchInterval={activityRefetchInterval}
|
|
209
|
+
title={activityTitle}
|
|
210
|
+
description={activityDescription}
|
|
211
|
+
// The feed is ALREADY narrowed to one actor by the injected client, so
|
|
212
|
+
// an actor picker here would only offer to contradict it.
|
|
213
|
+
showFilters={false}
|
|
214
|
+
emptyTitle={activityEmptyTitle}
|
|
215
|
+
emptyDescription={activityEmptyDescription}
|
|
216
|
+
classNames={classNames?.activityPanel}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
@@ -20,6 +20,16 @@ export type {
|
|
|
20
20
|
} from './domains-settings-page-default-class-names'
|
|
21
21
|
export { DOMAINS_SETTINGS_PAGE_DEFAULTS } from './domains-settings-page-default-class-names'
|
|
22
22
|
|
|
23
|
+
// "What has this person done" — one member's facts plus their slice of THE one
|
|
24
|
+
// permission-filtered event stream (startsim-768w.19.6). The activity client is
|
|
25
|
+
// injected already narrowed to that actor; this composer adds no feed of its own.
|
|
26
|
+
export { MemberDetailPanel } from './MemberDetailPanel'
|
|
27
|
+
export type { MemberDetailPanelProps } from './MemberDetailPanel'
|
|
28
|
+
export type {
|
|
29
|
+
MemberDetailPanelClassNames,
|
|
30
|
+
} from './member-detail-panel-default-class-names'
|
|
31
|
+
export { MEMBER_DETAIL_PANEL_DEFAULTS } from './member-detail-panel-default-class-names'
|
|
32
|
+
|
|
23
33
|
export type {
|
|
24
34
|
TeamSettingsApi,
|
|
25
35
|
DomainsSettingsApi,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link MemberDetailPanel}. startsim-768w.19.6.
|
|
2
|
+
* Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
|
|
3
|
+
import type { ActivityFeedPanelClassNames } from '../../activity/activity-feed-panel-default-class-names'
|
|
4
|
+
|
|
5
|
+
export interface MemberDetailPanelClassNames {
|
|
6
|
+
root?: string
|
|
7
|
+
/** Identity header: avatar + name + email + role/status pills. */
|
|
8
|
+
header?: string
|
|
9
|
+
identity?: string
|
|
10
|
+
avatar?: string
|
|
11
|
+
name?: string
|
|
12
|
+
email?: string
|
|
13
|
+
badges?: string
|
|
14
|
+
roleBadge?: string
|
|
15
|
+
statusBadge?: string
|
|
16
|
+
/** Per-state colour of the invite pill — an expired invite is an action item. */
|
|
17
|
+
statusActive?: string
|
|
18
|
+
statusPending?: string
|
|
19
|
+
statusExpired?: string
|
|
20
|
+
statusDeactivated?: string
|
|
21
|
+
/** The facts grid: last login / activated / invited by / role changed. */
|
|
22
|
+
facts?: string
|
|
23
|
+
fact?: string
|
|
24
|
+
factLabel?: string
|
|
25
|
+
factValue?: string
|
|
26
|
+
/** Muted rendering for a fact that has no value yet ("Never signed in"). */
|
|
27
|
+
factEmpty?: string
|
|
28
|
+
/** Row holding the invite callout + its Resend action. */
|
|
29
|
+
inviteRow?: string
|
|
30
|
+
inviteNote?: string
|
|
31
|
+
resendButton?: string
|
|
32
|
+
/** Wrapper around the embedded activity feed. */
|
|
33
|
+
activity?: string
|
|
34
|
+
/** Forwarded so an app still overrides one map. */
|
|
35
|
+
activityPanel?: ActivityFeedPanelClassNames
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const MEMBER_DETAIL_PANEL_DEFAULTS: Required<
|
|
39
|
+
Omit<MemberDetailPanelClassNames, 'activityPanel'>
|
|
40
|
+
> = {
|
|
41
|
+
root: 'space-y-5',
|
|
42
|
+
header: 'flex flex-wrap items-start justify-between gap-4',
|
|
43
|
+
identity: 'flex min-w-0 items-center gap-3',
|
|
44
|
+
avatar:
|
|
45
|
+
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary',
|
|
46
|
+
name: 'truncate text-lg font-semibold text-foreground',
|
|
47
|
+
email: 'truncate text-sm text-muted-foreground',
|
|
48
|
+
badges: 'flex flex-wrap items-center gap-2',
|
|
49
|
+
roleBadge:
|
|
50
|
+
'inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground',
|
|
51
|
+
statusBadge:
|
|
52
|
+
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium',
|
|
53
|
+
statusActive: 'border-border bg-muted text-muted-foreground',
|
|
54
|
+
statusPending: 'border-primary/30 bg-primary/10 text-primary',
|
|
55
|
+
statusExpired: 'border-destructive/40 bg-destructive/10 text-destructive',
|
|
56
|
+
statusDeactivated: 'border-border bg-muted/60 text-muted-foreground line-through',
|
|
57
|
+
facts: 'grid gap-4 rounded-lg border border-border bg-muted/30 p-4 sm:grid-cols-2',
|
|
58
|
+
fact: 'min-w-0 space-y-1',
|
|
59
|
+
factLabel: 'text-xs uppercase tracking-wide text-muted-foreground',
|
|
60
|
+
factValue: 'truncate text-sm text-foreground',
|
|
61
|
+
factEmpty: 'truncate text-sm text-muted-foreground/70',
|
|
62
|
+
inviteRow:
|
|
63
|
+
'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-4 py-3',
|
|
64
|
+
inviteNote: 'text-sm text-muted-foreground',
|
|
65
|
+
resendButton:
|
|
66
|
+
'rounded-md border border-border px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 hover:text-primary disabled:opacity-50',
|
|
67
|
+
activity: '',
|
|
68
|
+
}
|
|
@@ -62,6 +62,16 @@ export interface MemberRow {
|
|
|
62
62
|
invitedByEmail?: string | null
|
|
63
63
|
/** False for a membership that has been deactivated but not removed. */
|
|
64
64
|
isActive?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* When this person actually joined — read off the recorded activation event,
|
|
67
|
+
* not recomputed from "do they have a password yet" (startsim-768w.19.2).
|
|
68
|
+
* Null for anyone still holding an unused invite.
|
|
69
|
+
*/
|
|
70
|
+
activatedAt?: string | null
|
|
71
|
+
/** When their role was last changed after they joined. Null if never. */
|
|
72
|
+
roleChangedAt?: string | null
|
|
73
|
+
/** When the membership was suspended; null while active. */
|
|
74
|
+
deactivatedAt?: string | null
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
export interface InvitationLite {
|