@qoretechnologies/reqraft 0.10.64 → 0.10.65-pr.148.gc6381e4
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/.claude/CLAUDE.md +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +13 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.js +11 -4
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
- package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
- package/dist/components/supportTicket/ReferencePicker.js +1 -1
- package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
- package/dist/components/supportTicket/WorkspaceAccessStatus.d.ts +55 -0
- package/dist/components/supportTicket/WorkspaceAccessStatus.d.ts.map +1 -0
- package/dist/components/supportTicket/WorkspaceAccessStatus.js +79 -0
- package/dist/components/supportTicket/WorkspaceAccessStatus.js.map +1 -0
- package/dist/components/supportTicket/accessMeta.d.ts +50 -0
- package/dist/components/supportTicket/accessMeta.d.ts.map +1 -0
- package/dist/components/supportTicket/accessMeta.js +174 -0
- package/dist/components/supportTicket/accessMeta.js.map +1 -0
- package/dist/components/supportTicket/index.d.ts +2 -0
- package/dist/components/supportTicket/index.d.ts.map +1 -1
- package/dist/components/supportTicket/index.js +2 -0
- package/dist/components/supportTicket/index.js.map +1 -1
- package/dist/components/supportTicket/meta.d.ts +62 -2
- package/dist/components/supportTicket/meta.d.ts.map +1 -1
- package/dist/components/supportTicket/meta.js +116 -3
- package/dist/components/supportTicket/meta.js.map +1 -1
- package/dist/components/supportTicket/ticketModel.d.ts +10 -0
- package/dist/components/supportTicket/ticketModel.d.ts.map +1 -1
- package/dist/components/ticketReferences/TicketReferences.d.ts +10 -1
- package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
- package/dist/components/ticketReferences/TicketReferences.js +12 -8
- package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
- package/dist/components/ticketThread/TicketThread.d.ts +30 -1
- package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
- package/dist/components/ticketThread/TicketThread.js +51 -13
- package/dist/components/ticketThread/TicketThread.js.map +1 -1
- package/dist/components/ticketThread/index.d.ts +1 -1
- package/dist/components/ticketThread/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/providers/StorageProvider.d.ts +11 -0
- package/dist/providers/StorageProvider.d.ts.map +1 -1
- package/dist/providers/StorageProvider.js +21 -3
- package/dist/providers/StorageProvider.js.map +1 -1
- package/dist/stores/currentUser/currentUser.d.ts +42 -0
- package/dist/stores/currentUser/currentUser.d.ts.map +1 -1
- package/dist/stores/currentUser/currentUser.js.map +1 -1
- package/package.json +2 -2
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +56 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +27 -7
- package/src/components/supportTicket/ReferencePicker.tsx +4 -1
- package/src/components/supportTicket/WorkspaceAccessStatus.stories.tsx +105 -0
- package/src/components/supportTicket/WorkspaceAccessStatus.tsx +176 -0
- package/src/components/supportTicket/accessMeta.ts +210 -0
- package/src/components/supportTicket/index.ts +2 -0
- package/src/components/supportTicket/meta.ts +156 -4
- package/src/components/supportTicket/ticketModel.ts +10 -0
- package/src/components/ticketReferences/TicketReferences.stories.tsx +40 -0
- package/src/components/ticketReferences/TicketReferences.tsx +24 -10
- package/src/components/ticketThread/TicketThread.stories.tsx +103 -1
- package/src/components/ticketThread/TicketThread.tsx +131 -15
- package/src/components/ticketThread/index.ts +1 -0
- package/src/index.tsx +1 -0
- package/src/providers/StorageProvider.tsx +20 -3
- package/src/stores/currentUser/currentUser.tsx +43 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
|
|
3
|
+
import { StoryMeta } from '../../types';
|
|
4
|
+
import { TicketHeader } from './TicketHeader';
|
|
5
|
+
import { IWorkspaceAccessStatusProps, WorkspaceAccessStatus } from './WorkspaceAccessStatus';
|
|
6
|
+
|
|
7
|
+
/** The frozen clock every relative time here is spoken against. */
|
|
8
|
+
const NOW = Date.parse('2026-10-19T12:00:00Z');
|
|
9
|
+
const at = (minutes: number) => new Date(NOW + minutes * 60_000).toISOString();
|
|
10
|
+
|
|
11
|
+
const ACTIVE = { state: 'active', created_by_type: 'customer', issued_at: at(-180), expires_at: at(2 * 24 * 60), until_resolved: false };
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* One read-only value with many states: the review grid is one story, every state a
|
|
15
|
+
* labelled rail row, staff beside customer — one capture to look at, and a wrong word
|
|
16
|
+
* in any row still fails the play by name. The clicking case and the phone case are
|
|
17
|
+
* their own stories.
|
|
18
|
+
*/
|
|
19
|
+
const STATES: { label: string; props: Omit<IWorkspaceAccessStatusProps, 'now'> }[] = [
|
|
20
|
+
{ label: 'Active · staff', props: { viewerRole: 'staff', grant: ACTIVE } },
|
|
21
|
+
{ label: 'Active · customer', props: { viewerRole: 'customer', grant: ACTIVE } },
|
|
22
|
+
{ label: 'Expiring soon', props: { viewerRole: 'staff', grant: { ...ACTIVE, expires_at: at(40) } } },
|
|
23
|
+
{ label: 'Ended on the clock', props: { viewerRole: 'staff', grant: { ...ACTIVE, expires_at: at(-5) } } },
|
|
24
|
+
// six days, not seven: at seven the clock says "next week", which reads worse
|
|
25
|
+
{ label: 'Automatic, until resolved', props: { viewerRole: 'staff', grant: { ...ACTIVE, created_by_type: 'system', until_resolved: true, expires_at: at(6 * 24 * 60) } } },
|
|
26
|
+
{ label: 'Revoked', props: { viewerRole: 'staff', grant: { state: 'revoked', created_by_type: 'customer', issued_at: at(-3 * 24 * 60), expires_at: at(-24 * 60), ended_at: at(-26 * 60), end_cause: 'staff_revoked' } } },
|
|
27
|
+
{ label: 'Pending · staff', props: { viewerRole: 'staff', request: { requested_at: at(-45), suggested_duration: '72h' } } },
|
|
28
|
+
{ label: 'Pending · customer', props: { viewerRole: 'customer', request: { requested_at: at(-45), suggested_duration: '24h' } } },
|
|
29
|
+
{ label: 'Setting is never', props: { viewerRole: 'staff', policy: 'never' } },
|
|
30
|
+
// the row label must not repeat the value's own word, or the play finds two
|
|
31
|
+
{ label: 'Nothing yet', props: { viewerRole: 'staff' } },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const meta = {
|
|
35
|
+
component: WorkspaceAccessStatus,
|
|
36
|
+
title: 'Components/WorkspaceAccessStatus',
|
|
37
|
+
args: { viewerRole: 'staff', now: NOW, grant: ACTIVE },
|
|
38
|
+
// the value sits in the ticket rail, so each story renders it in one
|
|
39
|
+
render: (args) => <TicketHeader padding={14} properties={[{ label: 'Workspace access', value: <WorkspaceAccessStatus {...args} /> }]} />,
|
|
40
|
+
} as StoryMeta<typeof WorkspaceAccessStatus>;
|
|
41
|
+
|
|
42
|
+
export default meta;
|
|
43
|
+
type Story = StoryObj<typeof meta>;
|
|
44
|
+
|
|
45
|
+
export const States: Story = {
|
|
46
|
+
parameters: {
|
|
47
|
+
docs: {
|
|
48
|
+
description: {
|
|
49
|
+
story:
|
|
50
|
+
'Renders every state of the value as a labelled rail row against one frozen clock: an active grant from the staff and the customer side, one expiring within the hour (amber), one whose expiry passed (muted), an automatic grant until the ticket is resolved, a revoked one, an open request from both sides, a setting of "never", and none.',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
render: () => (
|
|
55
|
+
<TicketHeader
|
|
56
|
+
padding={14}
|
|
57
|
+
properties={STATES.map(({ label, props }) => ({ label, value: <WorkspaceAccessStatus {...props} now={NOW} /> }))}
|
|
58
|
+
/>
|
|
59
|
+
),
|
|
60
|
+
async play({ canvasElement }) {
|
|
61
|
+
const canvas = within(canvasElement);
|
|
62
|
+
for (const text of [
|
|
63
|
+
'Granted by the customer · ends in 2 days',
|
|
64
|
+
'Granted by you · ends in 2 days',
|
|
65
|
+
'Granted by the customer · ends in 40 minutes',
|
|
66
|
+
'Access ended 5 minutes ago',
|
|
67
|
+
'Granted automatically · ends in 6 days',
|
|
68
|
+
'Access revoked: ended by support · yesterday',
|
|
69
|
+
'Requested for 3 days · awaiting the customer · 45 minutes ago',
|
|
70
|
+
'Qorus Support asks for access for 24 hours · 45 minutes ago',
|
|
71
|
+
"Not allowed: the account's setting is never",
|
|
72
|
+
'None',
|
|
73
|
+
]) {
|
|
74
|
+
await expect(canvas.getByText(text)).toBeInTheDocument();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const Active: Story = {
|
|
80
|
+
args: { action: { label: 'End access', icon: 'ShieldCrossLine', intent: 'danger', onClick: fn() } },
|
|
81
|
+
parameters: {
|
|
82
|
+
docs: {
|
|
83
|
+
description: {
|
|
84
|
+
story: 'Renders the staff value of an active grant with "End access" beside it — the one state that carries an action. Clicking the action calls it.',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
async play({ canvasElement, args }) {
|
|
89
|
+
const canvas = within(canvasElement);
|
|
90
|
+
await expect(canvas.getByText('Granted by the customer · ends in 2 days')).toBeInTheDocument();
|
|
91
|
+
await userEvent.click(canvas.getByText('End access'));
|
|
92
|
+
await waitFor(() => expect(args.action?.onClick).toHaveBeenCalledTimes(1));
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const Mobile: Story = {
|
|
97
|
+
args: Active.args,
|
|
98
|
+
parameters: {
|
|
99
|
+
qlip: { viewport: { width: 390, height: 844 } },
|
|
100
|
+
docs: { description: { story: 'Renders the active value with its action at 390px: the sentence truncates with an ellipsis before the action does, and the action keeps its label.' } },
|
|
101
|
+
},
|
|
102
|
+
async play({ canvasElement }) {
|
|
103
|
+
await expect(within(canvasElement).getByText('End access')).toBeInTheDocument();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { ReqoreButton, ReqoreIcon, ReqoreSpan } from '@qoretechnologies/reqore';
|
|
2
|
+
import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
|
|
3
|
+
import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
|
|
4
|
+
import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
5
|
+
import {
|
|
6
|
+
formatInstant,
|
|
7
|
+
formatRelativeTime,
|
|
8
|
+
grantTimeIntent,
|
|
9
|
+
millisUntil,
|
|
10
|
+
supportAccessDurationLabel,
|
|
11
|
+
supportGrantEndCauseLabel,
|
|
12
|
+
supportGrantStateMeta,
|
|
13
|
+
TSupportAccessPolicy,
|
|
14
|
+
TSupportGrantState,
|
|
15
|
+
} from './accessMeta';
|
|
16
|
+
import { TTicketViewerRole } from './meta';
|
|
17
|
+
import { TicketPersonValue } from './TicketHeader';
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* Where a ticket's workspace access stands, as one rail value: granted and for how
|
|
21
|
+
* long, requested and waiting, refused by the setting, or none — with the one thing
|
|
22
|
+
* the viewer may do about it beside it (the customer's Revoke, support's End access).
|
|
23
|
+
* The customer's IDE and the staff portal show the same grant from opposite sides,
|
|
24
|
+
* so the sentence is built here once and `viewerRole` picks the pronoun.
|
|
25
|
+
*
|
|
26
|
+
* The clock is the caller's (`now`): the component never reads the wall clock, so a
|
|
27
|
+
* story freezes it and every surface on a page agrees on "ends in 2 days".
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** What the value needs of a grant: the plane's `SupportAccessGrant`, a subset. */
|
|
31
|
+
export interface IWorkspaceAccessGrantSummary {
|
|
32
|
+
state: TSupportGrantState | string;
|
|
33
|
+
issued_at?: string;
|
|
34
|
+
expires_at?: string;
|
|
35
|
+
until_resolved?: boolean;
|
|
36
|
+
created_by_type?: 'customer' | 'staff' | 'system' | string;
|
|
37
|
+
ended_at?: string;
|
|
38
|
+
end_cause?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the value needs of an open staff request. */
|
|
42
|
+
export interface IWorkspaceAccessRequestSummary {
|
|
43
|
+
requested_at: string;
|
|
44
|
+
suggested_duration?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface IWorkspaceAccessStatusAction {
|
|
48
|
+
label: string;
|
|
49
|
+
icon?: IReqoreIconName;
|
|
50
|
+
intent?: TReqoreIntent;
|
|
51
|
+
onClick: () => void;
|
|
52
|
+
loading?: boolean;
|
|
53
|
+
tooltip?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface IWorkspaceAccessStatusProps {
|
|
57
|
+
/** the ticket's active grant, or the last one when none is active */
|
|
58
|
+
grant?: IWorkspaceAccessGrantSummary;
|
|
59
|
+
/** the open staff request, when there is one and no active grant */
|
|
60
|
+
request?: IWorkspaceAccessRequestSummary;
|
|
61
|
+
/** the account's setting; `never` is said when nothing else is there to say */
|
|
62
|
+
policy?: TSupportAccessPolicy | string;
|
|
63
|
+
/** who is reading: decides "by you" against "by the customer" */
|
|
64
|
+
viewerRole: TTicketViewerRole;
|
|
65
|
+
/** the clock, in ms; a story passes a fixed one */
|
|
66
|
+
now: number;
|
|
67
|
+
/** the one thing the viewer may do right now, rendered beside the sentence */
|
|
68
|
+
action?: IWorkspaceAccessStatusAction;
|
|
69
|
+
/** the rail's size by default */
|
|
70
|
+
size?: TSizes;
|
|
71
|
+
className?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const grantedBy = (grant: IWorkspaceAccessGrantSummary, viewerRole: TTicketViewerRole): string => {
|
|
75
|
+
if (grant.created_by_type === 'system') return 'automatically';
|
|
76
|
+
if (grant.created_by_type === 'customer' || !grant.created_by_type) {
|
|
77
|
+
return viewerRole === 'customer' ? 'by you' : 'by the customer';
|
|
78
|
+
}
|
|
79
|
+
return 'by support';
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** The sentence, its mark and its colour for the state the props describe. */
|
|
83
|
+
export const describeWorkspaceAccess = (
|
|
84
|
+
{ grant, request, policy, viewerRole, now }: Pick<IWorkspaceAccessStatusProps, 'grant' | 'request' | 'policy' | 'viewerRole' | 'now'>
|
|
85
|
+
): { text: string; tooltip?: string; intent: TReqoreIntent; icon: IReqoreIconName; muted?: boolean } => {
|
|
86
|
+
if (grant && grant.state === 'active') {
|
|
87
|
+
const left = millisUntil(grant.expires_at, now);
|
|
88
|
+
if (!Number.isNaN(left) && left <= 0) {
|
|
89
|
+
return {
|
|
90
|
+
text: `Access ended ${formatRelativeTime(grant.expires_at, now)}`,
|
|
91
|
+
tooltip: formatInstant(grant.expires_at),
|
|
92
|
+
intent: 'muted',
|
|
93
|
+
icon: 'HourglassLine',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const ends = grant.expires_at ? ` · ends ${formatRelativeTime(grant.expires_at, now)}` : '';
|
|
97
|
+
return {
|
|
98
|
+
text: `Granted ${grantedBy(grant, viewerRole)}${ends}`,
|
|
99
|
+
tooltip: grant.expires_at
|
|
100
|
+
? `Until ${formatInstant(grant.expires_at)}${grant.until_resolved ? ', or when the ticket is resolved' : ''}`
|
|
101
|
+
: undefined,
|
|
102
|
+
intent: grantTimeIntent(grant.expires_at, now),
|
|
103
|
+
icon: 'ShieldCheckLine',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (grant) {
|
|
107
|
+
const meta = supportGrantStateMeta(grant.state);
|
|
108
|
+
const cause = supportGrantEndCauseLabel(grant.end_cause);
|
|
109
|
+
const when = formatRelativeTime(grant.ended_at ?? grant.expires_at, now);
|
|
110
|
+
return {
|
|
111
|
+
text: `Access ${meta.label.toLowerCase()}${cause && cause !== meta.label.toLowerCase() ? `: ${cause}` : ''}${when ? ` · ${when}` : ''}`,
|
|
112
|
+
tooltip: formatInstant(grant.ended_at ?? grant.expires_at) || undefined,
|
|
113
|
+
intent: meta.intent,
|
|
114
|
+
icon: meta.icon,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (request) {
|
|
118
|
+
const since = formatRelativeTime(request.requested_at, now);
|
|
119
|
+
const duration = request.suggested_duration ? ` for ${supportAccessDurationLabel(request.suggested_duration)}` : '';
|
|
120
|
+
return {
|
|
121
|
+
text:
|
|
122
|
+
viewerRole === 'customer'
|
|
123
|
+
? `Qorus Support asks for access${duration} · ${since}`
|
|
124
|
+
: `Requested${duration} · awaiting the customer · ${since}`,
|
|
125
|
+
tooltip: formatInstant(request.requested_at),
|
|
126
|
+
intent: 'pending',
|
|
127
|
+
icon: 'TimeLine',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (policy === 'never') {
|
|
131
|
+
return {
|
|
132
|
+
text: viewerRole === 'customer' ? 'Off: your setting is never' : "Not allowed: the account's setting is never",
|
|
133
|
+
intent: 'danger',
|
|
134
|
+
icon: 'ShieldCrossLine',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { text: 'None', intent: 'muted', icon: 'ShieldLine', muted: true };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const WorkspaceAccessStatus = ({
|
|
141
|
+
grant,
|
|
142
|
+
request,
|
|
143
|
+
policy,
|
|
144
|
+
viewerRole,
|
|
145
|
+
now,
|
|
146
|
+
action,
|
|
147
|
+
size = 'small',
|
|
148
|
+
className,
|
|
149
|
+
}: IWorkspaceAccessStatusProps) => {
|
|
150
|
+
const { text, tooltip, intent, icon, muted } = describeWorkspaceAccess({ grant, request, policy, viewerRole, now });
|
|
151
|
+
return (
|
|
152
|
+
<TicketPersonValue className={`reqore-workspace-access-status${className ? ` ${className}` : ''}`}>
|
|
153
|
+
{/* intent as a dot, the way the rail's other state values read: the colour
|
|
154
|
+
carries the state without a saturated chip in a row of reference material */}
|
|
155
|
+
<ReqoreIcon icon={icon} size='tiny' color={intent as never} />
|
|
156
|
+
<ReqoreSpan size={size} tooltip={tooltip} effect={muted ? { opacity: 0.5 } : undefined}>
|
|
157
|
+
{text}
|
|
158
|
+
</ReqoreSpan>
|
|
159
|
+
{action ? (
|
|
160
|
+
<ReqoreButton
|
|
161
|
+
size={size}
|
|
162
|
+
minimal
|
|
163
|
+
flat
|
|
164
|
+
icon={action.icon}
|
|
165
|
+
intent={action.intent}
|
|
166
|
+
loading={action.loading}
|
|
167
|
+
tooltip={action.tooltip}
|
|
168
|
+
onClick={action.onClick}
|
|
169
|
+
className='reqore-workspace-access-status-action'
|
|
170
|
+
>
|
|
171
|
+
{action.label}
|
|
172
|
+
</ReqoreButton>
|
|
173
|
+
) : null}
|
|
174
|
+
</TicketPersonValue>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
|
|
2
|
+
import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
3
|
+
import { ITicketEnumMeta, humaniseWireValue } from './meta';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* The delegated-support-access vocabulary (qorus#692): how the account's access
|
|
7
|
+
* setting, a grant's state, the reasons a grant ended, the duration presets and the
|
|
8
|
+
* grant's own audit trail read on every surface that shows them — the customer's IDE
|
|
9
|
+
* (the sign-up step, the account tab, the ticket drawer) and the staff portal (the
|
|
10
|
+
* workspace tab, the tenant card). The wire values are the SaaS plane's
|
|
11
|
+
* (`QorusSaasSupportAccessStore`, `QorusSupportSessionStore`); the words are shared so
|
|
12
|
+
* the two sides describe one grant the same way. The customer-facing LEGAL wording of
|
|
13
|
+
* the setting is not here: the plane versions and serves it with the choice.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The account's support access setting, least to most restrictive. */
|
|
17
|
+
export type TSupportAccessPolicy = 'auto' | 'ask' | 'never';
|
|
18
|
+
|
|
19
|
+
/** Where the governing setting came from. `default` when the customer never chose;
|
|
20
|
+
* `owner` when the workspace's billing owner's stricter setting governs the ticket. */
|
|
21
|
+
export type TSupportAccessPolicySource = 'default' | 'signup' | 'prompt' | 'settings' | 'admin' | 'owner';
|
|
22
|
+
|
|
23
|
+
export type TSupportGrantState = 'active' | 'revoked' | 'expired';
|
|
24
|
+
|
|
25
|
+
export type TSupportGrantEndCause =
|
|
26
|
+
| 'customer_revoked'
|
|
27
|
+
| 'staff_revoked'
|
|
28
|
+
| 'superseded'
|
|
29
|
+
| 'ticket_resolved'
|
|
30
|
+
| 'ticket_closed'
|
|
31
|
+
| 'policy_changed'
|
|
32
|
+
| 'account_erased'
|
|
33
|
+
| 'workspace_deleted'
|
|
34
|
+
| 'expired';
|
|
35
|
+
|
|
36
|
+
/** A duration a staff request suggests; the customer's Grant answers exactly it. */
|
|
37
|
+
export type TSupportAccessDuration = '24h' | '72h' | '7d' | 'until_resolved';
|
|
38
|
+
|
|
39
|
+
/** The grant's own audit trail (`…/access` `audit`, the account's access log). */
|
|
40
|
+
export type TSupportAccessAuditAction =
|
|
41
|
+
| 'policy_set'
|
|
42
|
+
| 'access_requested'
|
|
43
|
+
| 'granted'
|
|
44
|
+
| 'extended'
|
|
45
|
+
| 'revoked'
|
|
46
|
+
| 'expired'
|
|
47
|
+
| 'used'
|
|
48
|
+
| 'use_refused'
|
|
49
|
+
| 'session_started'
|
|
50
|
+
| 'session_ended';
|
|
51
|
+
|
|
52
|
+
/** A mark with a sentence: the setting's meaning is what a customer decides on. */
|
|
53
|
+
export interface ISupportAccessMeta extends ITicketEnumMeta {
|
|
54
|
+
description?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `idle_paused` → "Idle paused": a wire value as words. */
|
|
58
|
+
|
|
59
|
+
const UNKNOWN: ISupportAccessMeta = { label: 'Unknown', intent: 'muted', icon: 'QuestionLine' };
|
|
60
|
+
|
|
61
|
+
const lookup =
|
|
62
|
+
<K extends string>(table: Record<K, ISupportAccessMeta>) =>
|
|
63
|
+
(value: string | undefined): ISupportAccessMeta =>
|
|
64
|
+
value === undefined
|
|
65
|
+
? UNKNOWN
|
|
66
|
+
: ((table as Record<string, ISupportAccessMeta>)[value] ?? { ...UNKNOWN, label: humaniseWireValue(value) });
|
|
67
|
+
|
|
68
|
+
export const SUPPORT_ACCESS_POLICY_META: Record<TSupportAccessPolicy, ISupportAccessMeta> = {
|
|
69
|
+
auto: {
|
|
70
|
+
label: 'Granted automatically',
|
|
71
|
+
intent: 'success',
|
|
72
|
+
icon: 'ShieldCheckLine',
|
|
73
|
+
description: 'Read-only access for Qorus Support is granted automatically on every ticket filed.',
|
|
74
|
+
},
|
|
75
|
+
ask: {
|
|
76
|
+
label: 'Ask the customer',
|
|
77
|
+
intent: 'info',
|
|
78
|
+
icon: 'QuestionAnswerLine',
|
|
79
|
+
description: 'Support asks on each ticket; the customer decides whether to grant access.',
|
|
80
|
+
},
|
|
81
|
+
never: {
|
|
82
|
+
label: 'Never',
|
|
83
|
+
intent: 'danger',
|
|
84
|
+
icon: 'ShieldCrossLine',
|
|
85
|
+
description: 'No workspace access for Qorus Support. Support asks for information in the thread instead.',
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
/** The setting's mark; an unknown value still reads as words. */
|
|
89
|
+
export const supportAccessPolicyMeta = lookup(SUPPORT_ACCESS_POLICY_META);
|
|
90
|
+
|
|
91
|
+
export const SUPPORT_ACCESS_POLICY_SOURCE_LABEL: Record<TSupportAccessPolicySource, string> = {
|
|
92
|
+
default: 'the default, never chosen',
|
|
93
|
+
signup: 'chosen at sign-up',
|
|
94
|
+
prompt: 'chosen when first asked',
|
|
95
|
+
settings: 'chosen in the account settings',
|
|
96
|
+
admin: 'set by Qorus staff',
|
|
97
|
+
owner: "the workspace owner's stricter setting",
|
|
98
|
+
};
|
|
99
|
+
export const supportAccessPolicySourceLabel = (source: string | undefined): string =>
|
|
100
|
+
(source && (SUPPORT_ACCESS_POLICY_SOURCE_LABEL as Record<string, string>)[source]) ||
|
|
101
|
+
humaniseWireValue(source ?? '');
|
|
102
|
+
|
|
103
|
+
export const SUPPORT_GRANT_STATE_META: Record<TSupportGrantState, ISupportAccessMeta> = {
|
|
104
|
+
active: { label: 'Active', intent: 'success', icon: 'ShieldCheckLine' },
|
|
105
|
+
revoked: { label: 'Revoked', intent: 'danger', icon: 'ShieldCrossLine' },
|
|
106
|
+
expired: { label: 'Expired', intent: 'muted', icon: 'HourglassLine' },
|
|
107
|
+
};
|
|
108
|
+
export const supportGrantStateMeta = lookup(SUPPORT_GRANT_STATE_META);
|
|
109
|
+
|
|
110
|
+
export const SUPPORT_GRANT_END_CAUSE_LABEL: Record<TSupportGrantEndCause, string> = {
|
|
111
|
+
customer_revoked: 'ended by the customer',
|
|
112
|
+
staff_revoked: 'ended by support',
|
|
113
|
+
superseded: 'extended into a new grant',
|
|
114
|
+
ticket_resolved: 'the ticket was resolved',
|
|
115
|
+
ticket_closed: 'the ticket was closed',
|
|
116
|
+
policy_changed: "the account's setting changed",
|
|
117
|
+
account_erased: 'the account was erased',
|
|
118
|
+
workspace_deleted: 'the workspace was deleted',
|
|
119
|
+
expired: 'expired',
|
|
120
|
+
};
|
|
121
|
+
export const supportGrantEndCauseLabel = (cause: string | undefined): string | undefined =>
|
|
122
|
+
cause ? ((SUPPORT_GRANT_END_CAUSE_LABEL as Record<string, string>)[cause] ?? humaniseWireValue(cause)) : undefined;
|
|
123
|
+
|
|
124
|
+
export const SUPPORT_ACCESS_DURATION_LABEL: Record<TSupportAccessDuration, string> = {
|
|
125
|
+
'24h': '24 hours',
|
|
126
|
+
'72h': '3 days',
|
|
127
|
+
'7d': '7 days',
|
|
128
|
+
until_resolved: 'until the ticket is resolved',
|
|
129
|
+
};
|
|
130
|
+
export const supportAccessDurationLabel = (duration: string | undefined): string =>
|
|
131
|
+
(duration && (SUPPORT_ACCESS_DURATION_LABEL as Record<string, string>)[duration]) ||
|
|
132
|
+
humaniseWireValue(duration ?? '');
|
|
133
|
+
|
|
134
|
+
/** The grant's own trail, as a customer's access log or a staff access view lists it. */
|
|
135
|
+
export const SUPPORT_ACCESS_AUDIT_ACTION_META: Record<TSupportAccessAuditAction, ISupportAccessMeta> = {
|
|
136
|
+
policy_set: { label: 'Setting changed', intent: 'info', icon: 'Settings3Line' },
|
|
137
|
+
access_requested: { label: 'Access requested', intent: 'pending', icon: 'ShieldUserLine' },
|
|
138
|
+
granted: { label: 'Access granted', intent: 'success', icon: 'ShieldCheckLine' },
|
|
139
|
+
extended: { label: 'Access extended', intent: 'info', icon: 'TimerLine' },
|
|
140
|
+
revoked: { label: 'Access revoked', intent: 'danger', icon: 'ShieldCrossLine' },
|
|
141
|
+
expired: { label: 'Access expired', intent: 'muted', icon: 'HourglassLine' },
|
|
142
|
+
used: { label: 'Workspace read', intent: 'info', icon: 'EyeLine' },
|
|
143
|
+
use_refused: { label: 'Read refused', intent: 'warning', icon: 'ForbidLine' },
|
|
144
|
+
session_started: { label: 'IDE session started', intent: 'info', icon: 'ExternalLinkLine' },
|
|
145
|
+
session_ended: { label: 'IDE session ended', intent: 'muted', icon: 'LogoutBoxLine' },
|
|
146
|
+
};
|
|
147
|
+
/** An action the plane writes that this vocabulary has not caught up with still renders. */
|
|
148
|
+
export const supportAccessAuditActionMeta = (action: string): ISupportAccessMeta =>
|
|
149
|
+
(SUPPORT_ACCESS_AUDIT_ACTION_META as Record<string, ISupportAccessMeta>)[action] ?? {
|
|
150
|
+
...UNKNOWN,
|
|
151
|
+
icon: 'HistoryLine' as IReqoreIconName,
|
|
152
|
+
label: humaniseWireValue(action),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/* ── time, against a clock the caller owns ───────────────────────────────────── */
|
|
156
|
+
|
|
157
|
+
const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
|
158
|
+
['year', 60 * 60 * 24 * 365],
|
|
159
|
+
['month', 60 * 60 * 24 * 30],
|
|
160
|
+
['week', 60 * 60 * 24 * 7],
|
|
161
|
+
['day', 60 * 60 * 24],
|
|
162
|
+
['hour', 60 * 60],
|
|
163
|
+
['minute', 60],
|
|
164
|
+
['second', 1],
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* "in 3 hours" / "2 days ago" against a clock the caller passes, so a story freezes it
|
|
169
|
+
* and two surfaces reading the same grant agree. An unparsable instant is an empty
|
|
170
|
+
* string, never "NaN years ago".
|
|
171
|
+
*
|
|
172
|
+
* Not `epoch-timeago` (what `ReqoreTimeAgo` and `TicketReferences` use): it speaks only
|
|
173
|
+
* of the past and reads the real clock, so it can neither say "in 3 hours" for a grant
|
|
174
|
+
* that is still running nor hold still in a story.
|
|
175
|
+
*/
|
|
176
|
+
export const formatRelativeTime = (iso: string | undefined, now: number, locale = 'en'): string => {
|
|
177
|
+
if (!iso) return '';
|
|
178
|
+
const then = new Date(iso).getTime();
|
|
179
|
+
if (Number.isNaN(then)) return '';
|
|
180
|
+
const seconds = Math.round((then - now) / 1000);
|
|
181
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
|
182
|
+
for (const [unit, secondsInUnit] of RELATIVE_UNITS) {
|
|
183
|
+
if (Math.abs(seconds) >= secondsInUnit || unit === 'second') {
|
|
184
|
+
return rtf.format(Math.round(seconds / secondsInUnit), unit);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return '';
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** The absolute instant, for a tooltip beside a relative one. */
|
|
191
|
+
export const formatInstant = (iso: string | undefined, locale = 'en-GB'): string => {
|
|
192
|
+
if (!iso) return '';
|
|
193
|
+
const date = new Date(iso);
|
|
194
|
+
return Number.isNaN(date.getTime())
|
|
195
|
+
? iso
|
|
196
|
+
: date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Milliseconds until an instant; negative once it passed; NaN for nothing. */
|
|
200
|
+
export const millisUntil = (iso: string | undefined, now: number): number =>
|
|
201
|
+
iso ? new Date(iso).getTime() - now : Number.NaN;
|
|
202
|
+
|
|
203
|
+
/** The intent a grant's remaining time reads as: the state's, amber under an hour, muted once passed. */
|
|
204
|
+
export const EXPIRING_SOON_MS = 60 * 60 * 1000;
|
|
205
|
+
export const grantTimeIntent = (expiresAt: string | undefined, now: number): TReqoreIntent => {
|
|
206
|
+
const left = millisUntil(expiresAt, now);
|
|
207
|
+
if (Number.isNaN(left)) return 'success';
|
|
208
|
+
if (left <= 0) return 'muted';
|
|
209
|
+
return left < EXPIRING_SOON_MS ? 'warning' : 'success';
|
|
210
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from './meta';
|
|
2
|
+
export * from './accessMeta';
|
|
2
3
|
export * from './ticketModel';
|
|
3
4
|
export * from './attachmentAccess';
|
|
4
5
|
export * from './AttachmentChips';
|
|
@@ -8,3 +9,4 @@ export * from './InterfaceReferenceTags';
|
|
|
8
9
|
export * from './ReferencePicker';
|
|
9
10
|
export * from './fileToAttachment';
|
|
10
11
|
export * from './TicketReplyBox';
|
|
12
|
+
export * from './WorkspaceAccessStatus';
|