@equinor/fusion-framework-dev-portal 9.0.0 → 10.0.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@equinor/fusion-framework-dev-portal",
3
- "version": "9.0.0",
3
+ "version": "10.0.1",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "module": "./dist/main.js",
@@ -26,11 +26,13 @@
26
26
  "@equinor/fusion-react-styles": "^2.1.0",
27
27
  "@equinor/fusion-wc-chip": "^1.2.2",
28
28
  "@equinor/fusion-wc-person": "^3.5.5",
29
+ "@testing-library/react": "^16.0.0",
29
30
  "@types/react": "^19.2.7",
30
31
  "@types/react-dom": "^19.2.3",
31
32
  "@types/semver": "^7.7.1",
32
33
  "@vitejs/plugin-react": "^6.0.1",
33
34
  "dotenv": "^17.3.1",
35
+ "happy-dom": "^20.8.4",
34
36
  "react": "^19.2.1",
35
37
  "react-dom": "^19.2.1",
36
38
  "rxjs": "^7.8.1",
@@ -39,47 +41,48 @@
39
41
  "tsx": "^4.19.3",
40
42
  "typescript": "^7.0.2",
41
43
  "vite": "^8.0.0",
42
- "@equinor/fusion-framework-app": "12.0.0",
44
+ "vitest": "^4.1.0",
45
+ "@equinor/fusion-framework": "8.0.14",
46
+ "@equinor/fusion-framework-app": "13.0.0",
43
47
  "@equinor/fusion-framework-dev-server": "2.0.18",
44
- "@equinor/fusion-framework": "8.0.13",
45
- "@equinor/fusion-framework-module-app": "8.0.4",
46
- "@equinor/fusion-framework-module-bookmark": "4.0.4",
47
48
  "@equinor/fusion-framework-module-ag-grid": "37.0.1",
48
- "@equinor/fusion-framework-module-context": "8.0.2",
49
49
  "@equinor/fusion-framework-module-analytics": "3.0.5",
50
- "@equinor/fusion-framework-plugin-context-navigation": "0.1.0",
50
+ "@equinor/fusion-framework-module-app": "8.0.4",
51
+ "@equinor/fusion-framework-module-context": "8.0.2",
52
+ "@equinor/fusion-framework-plugin-context-navigation": "0.1.1",
53
+ "@equinor/fusion-framework-module-bookmark": "4.0.4",
51
54
  "@equinor/fusion-framework-module-feature-flag": "2.0.3",
52
55
  "@equinor/fusion-framework-module-navigation": "7.0.7",
56
+ "@equinor/fusion-framework-module-services": "8.1.0",
53
57
  "@equinor/fusion-framework-module-telemetry": "7.0.2",
54
58
  "@equinor/fusion-framework-react": "8.0.1",
55
- "@equinor/fusion-framework-module-services": "8.0.3",
59
+ "@equinor/fusion-framework-react-components-people-provider": "2.0.4",
60
+ "@equinor/fusion-framework-react-module-bookmark": "6.0.1",
56
61
  "@equinor/fusion-framework-react-components-bookmark": "2.0.3",
57
62
  "@equinor/fusion-framework-react-router": "2.4.0",
58
63
  "@equinor/fusion-observable": "9.1.1",
59
- "@equinor/fusion-query": "7.0.3",
60
- "@equinor/fusion-framework-react-components-people-provider": "2.0.4",
61
- "@equinor/fusion-framework-react-module-bookmark": "6.0.1"
64
+ "@equinor/fusion-query": "7.0.3"
62
65
  },
63
66
  "peerDependencies": {
64
- "@equinor/fusion-framework": "8.0.13",
65
- "@equinor/fusion-framework-app": "12.0.0",
67
+ "@equinor/fusion-framework": "8.0.14",
68
+ "@equinor/fusion-framework-app": "13.0.0",
66
69
  "@equinor/fusion-framework-module-ag-grid": "37.0.1",
67
70
  "@equinor/fusion-framework-dev-server": "2.0.18",
68
- "@equinor/fusion-framework-module-app": "8.0.4",
69
71
  "@equinor/fusion-framework-module-analytics": "3.0.5",
70
- "@equinor/fusion-framework-module-bookmark": "4.0.4",
71
- "@equinor/fusion-framework-module-context": "8.0.2",
72
- "@equinor/fusion-framework-plugin-context-navigation": "0.1.0",
72
+ "@equinor/fusion-framework-plugin-context-navigation": "0.1.1",
73
73
  "@equinor/fusion-framework-module-feature-flag": "2.0.3",
74
+ "@equinor/fusion-framework-module-app": "8.0.4",
75
+ "@equinor/fusion-framework-module-context": "8.0.2",
74
76
  "@equinor/fusion-framework-module-navigation": "7.0.7",
75
- "@equinor/fusion-framework-module-services": "8.0.3",
76
- "@equinor/fusion-framework-react-components-bookmark": "2.0.3",
77
+ "@equinor/fusion-framework-module-services": "8.1.0",
77
78
  "@equinor/fusion-framework-module-telemetry": "7.0.2",
79
+ "@equinor/fusion-framework-module-bookmark": "4.0.4",
80
+ "@equinor/fusion-framework-react-components-bookmark": "2.0.3",
78
81
  "@equinor/fusion-framework-react-components-people-provider": "2.0.4",
79
82
  "@equinor/fusion-framework-react": "8.0.1",
80
- "@equinor/fusion-framework-react-module-bookmark": "6.0.1",
81
83
  "@equinor/fusion-observable": "9.1.1",
82
- "@equinor/fusion-query": "7.0.3"
84
+ "@equinor/fusion-query": "7.0.3",
85
+ "@equinor/fusion-framework-react-module-bookmark": "6.0.1"
83
86
  },
84
87
  "peerDependenciesMeta": {
85
88
  "@equinor/fusion-framework": {
@@ -140,6 +143,7 @@
140
143
  "scripts": {
141
144
  "start": "tsx dev-server.ts",
142
145
  "build": "tsc -b --noCheck && vite build",
143
- "build:clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo && pnpm build"
146
+ "build:clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo && pnpm build",
147
+ "test": "vitest run"
144
148
  }
145
149
  }
@@ -5,7 +5,7 @@ PersonListItem;
5
5
 
6
6
  import { Divider } from '@equinor/eds-core-react';
7
7
 
8
- import { LandingSheetContent, FeatureSheetContent } from './sheets';
8
+ import { LandingSheetContent, FeatureSheetContent, RolesSheetContent } from './sheets';
9
9
 
10
10
  /** Props for the {@link PersonSideSheet} component. */
11
11
  type PersonSideSheetProps = {
@@ -33,6 +33,8 @@ export const PersonSideSheet = ({ azureId, isOpen, onClose }: PersonSideSheetPro
33
33
  switch (currentSheet) {
34
34
  case 'features':
35
35
  return FeatureSheetContent;
36
+ case 'roles':
37
+ return RolesSheetContent;
36
38
  default:
37
39
  return LandingSheetContent;
38
40
  }
@@ -1,6 +1,6 @@
1
1
  import { Divider, Icon, Button } from '@equinor/eds-core-react';
2
- import { bandage, category, work_outline, security } from '@equinor/eds-icons';
3
- Icon.add({ bandage, category, work_outline, security });
2
+ import { bandage, category, work_outline, security, verified_user } from '@equinor/eds-icons';
3
+ Icon.add({ bandage, category, work_outline, security, verified_user });
4
4
 
5
5
  import type { SheetContentProps } from './types';
6
6
 
@@ -33,6 +33,12 @@ export const LandingSheetContent = ({ azureId, navigate }: SheetContentProps) =>
33
33
  My Features
34
34
  </Button>
35
35
  </BtnListItem>
36
+ <BtnListItem>
37
+ <Button variant="ghost" onClick={() => navigate('roles')}>
38
+ <Icon name="verified_user" />
39
+ My Roles
40
+ </Button>
41
+ </BtnListItem>
36
42
  </BtnList>
37
43
  <Divider />
38
44
  <BtnList>
@@ -1,2 +1,3 @@
1
1
  export { FeatureSheetContent } from './FeatureSheetContent';
2
2
  export { LandingSheetContent } from './LandingSheetContent';
3
+ export { RolesSheetContent } from './roles';
@@ -0,0 +1,83 @@
1
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { ClaimableRole } from './ClaimableRole';
5
+ import type { ClaimableRoleAssignment } from './RolesApi';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ createClient: vi.fn(),
9
+ currentUser: { localAccountId: 'account-id' },
10
+ }));
11
+
12
+ vi.mock('@equinor/fusion-framework-react', () => ({
13
+ useFramework: () => ({
14
+ modules: { serviceDiscovery: { createClient: mocks.createClient } },
15
+ }),
16
+ }));
17
+
18
+ vi.mock('@equinor/fusion-framework-react/hooks', () => ({
19
+ useCurrentUser: () => mocks.currentUser,
20
+ }));
21
+
22
+ const assignment: ClaimableRoleAssignment = {
23
+ id: 'assignment-id',
24
+ claimableRole: {
25
+ name: 'Developer',
26
+ displayName: 'Developer role',
27
+ description: 'Temporary development access',
28
+ },
29
+ isActive: false,
30
+ activeTo: null,
31
+ };
32
+
33
+ describe('ClaimableRole', () => {
34
+ beforeEach(() => {
35
+ mocks.createClient.mockReset();
36
+ });
37
+
38
+ afterEach(() => {
39
+ cleanup();
40
+ });
41
+
42
+ it('requires a reason before activating a role', async () => {
43
+ const json = vi.fn();
44
+ mocks.createClient.mockResolvedValue({ json });
45
+
46
+ render(<ClaimableRole assignment={assignment} onChange={vi.fn()} />);
47
+
48
+ fireEvent.click(screen.getByLabelText('Activate Developer role'));
49
+ fireEvent.click(screen.getByRole('button', { name: 'Activate' }));
50
+
51
+ expect(await screen.findByText('Reason is required.')).toBeTruthy();
52
+ expect(json).not.toHaveBeenCalled();
53
+ });
54
+
55
+ it('activates a role and reports its updated state', async () => {
56
+ const json = vi.fn().mockResolvedValue({ activeToDate: '2026-08-07T16:00:00Z' });
57
+ const onChange = vi.fn();
58
+ mocks.createClient.mockResolvedValue({ json });
59
+
60
+ render(<ClaimableRole assignment={assignment} onChange={onChange} />);
61
+
62
+ fireEvent.click(screen.getByLabelText('Activate Developer role'));
63
+ fireEvent.change(screen.getByLabelText('Reason for activation'), {
64
+ target: { value: 'Testing role-dependent behavior' },
65
+ });
66
+ fireEvent.click(screen.getByRole('button', { name: 'Activate' }));
67
+
68
+ await waitFor(() =>
69
+ expect(onChange).toHaveBeenCalledWith({
70
+ ...assignment,
71
+ isActive: true,
72
+ activeTo: '2026-08-07T16:00:00Z',
73
+ }),
74
+ );
75
+ expect(json).toHaveBeenCalledWith(
76
+ '/accounts/account-id/claimable-role-assignments/assignment-id/activate',
77
+ {
78
+ method: 'POST',
79
+ body: JSON.stringify({ reason: 'Testing role-dependent behavior', hours: 2 }),
80
+ },
81
+ );
82
+ });
83
+ });
@@ -0,0 +1,229 @@
1
+ import {
2
+ Banner,
3
+ Button,
4
+ CircularProgress,
5
+ InputWrapper,
6
+ Slider,
7
+ Switch,
8
+ Textarea,
9
+ Typography,
10
+ } from '@equinor/eds-core-react';
11
+ import { useFramework } from '@equinor/fusion-framework-react';
12
+ import { useCurrentUser } from '@equinor/fusion-framework-react/hooks';
13
+ import type { ChangeEvent, ReactElement } from 'react';
14
+ import { useState } from 'react';
15
+ import styled from 'styled-components';
16
+
17
+ import { RolesApi, type ClaimableRoleAssignment } from './RolesApi';
18
+
19
+ interface ClaimableRoleProps {
20
+ readonly assignment: ClaimableRoleAssignment;
21
+ onChange(assignment: ClaimableRoleAssignment): void;
22
+ }
23
+
24
+ const Styled = {
25
+ Role: styled.div<{ $isClaiming: boolean }>`
26
+ display: flex;
27
+ flex-direction: column;
28
+ gap: 0.5rem;
29
+ padding: 0.5rem;
30
+ border: 1px solid ${({ $isClaiming }) => ($isClaiming ? '#6f6f6f' : 'transparent')};
31
+ border-radius: 4px;
32
+ `,
33
+ Summary: styled.div`
34
+ display: flex;
35
+ gap: 1rem;
36
+ align-items: center;
37
+ `,
38
+ Name: styled.div`
39
+ flex: 1;
40
+ `,
41
+ Indicator: styled.div<{ $active: boolean }>`
42
+ width: 0.25rem;
43
+ height: 2.5rem;
44
+ background: ${({ $active }) => ($active ? '#007079' : '#dcdcdc')};
45
+ `,
46
+ Form: styled.div`
47
+ display: flex;
48
+ flex-direction: column;
49
+ gap: 1rem;
50
+ padding: 0.5rem;
51
+ `,
52
+ Actions: styled.div`
53
+ display: flex;
54
+ justify-content: flex-end;
55
+ gap: 0.5rem;
56
+ `,
57
+ };
58
+
59
+ /**
60
+ * Renders one claimable role with the production activation and deactivation workflow.
61
+ *
62
+ * @param props.assignment - Consolidated role assignment to display and mutate.
63
+ * @param props.onChange - Reports successful activation state changes to the parent tab.
64
+ * @returns A role row with activation controls.
65
+ */
66
+ export const ClaimableRole = ({ assignment, onChange }: ClaimableRoleProps): ReactElement => {
67
+ const framework = useFramework();
68
+ const user = useCurrentUser();
69
+ const [isClaiming, setIsClaiming] = useState(false);
70
+ const [isPending, setIsPending] = useState(false);
71
+ const [durationHours, setDurationHours] = useState(2);
72
+ const [reason, setReason] = useState('');
73
+ const [error, setError] = useState<string>();
74
+ const now = Date.now();
75
+ const isUpcoming = Boolean(
76
+ assignment.validFrom && new Date(assignment.validFrom).getTime() > now,
77
+ );
78
+ const isExpired = Boolean(assignment.validTo && new Date(assignment.validTo).getTime() <= now);
79
+ const isUnavailable = isUpcoming || isExpired;
80
+ const availabilityLabel = isUpcoming ? 'Upcoming' : isExpired ? 'Expired' : undefined;
81
+
82
+ /** Resets the activation form without changing the current assignment. */
83
+ const resetForm = (): void => {
84
+ setIsClaiming(false);
85
+ setDurationHours(2);
86
+ setReason('');
87
+ setError(undefined);
88
+ };
89
+
90
+ /** Activates the role for the selected duration after validating the required reason. */
91
+ const activateRole = async (): Promise<void> => {
92
+ // The backend requires a meaningful reason for every temporary privilege elevation.
93
+ if (!reason.trim()) {
94
+ setError('Reason is required.');
95
+ return;
96
+ }
97
+
98
+ // Role mutations require the Roles V2 account identifier from the authenticated session.
99
+ if (!user?.localAccountId) {
100
+ setError('Unable to resolve the signed-in Fusion account.');
101
+ return;
102
+ }
103
+
104
+ setIsPending(true);
105
+ setError(undefined);
106
+
107
+ try {
108
+ const client = await framework.modules.serviceDiscovery.createClient('rolesv2');
109
+ const result = await new RolesApi(client, user.localAccountId).activateRole(
110
+ assignment.id,
111
+ reason.trim(),
112
+ durationHours,
113
+ );
114
+ onChange({ ...assignment, isActive: true, activeTo: result.activeToDate });
115
+ resetForm();
116
+ } catch (cause) {
117
+ setError(cause instanceof Error ? cause.message : 'Failed to activate role.');
118
+ } finally {
119
+ setIsPending(false);
120
+ }
121
+ };
122
+
123
+ /** Deactivates an active claimable role and reports the updated assignment. */
124
+ const deactivateRole = async (): Promise<void> => {
125
+ // Role mutations require the Roles V2 account identifier from the authenticated session.
126
+ if (!user?.localAccountId) {
127
+ setError('Unable to resolve the signed-in Fusion account.');
128
+ return;
129
+ }
130
+
131
+ setIsPending(true);
132
+ setError(undefined);
133
+
134
+ try {
135
+ const client = await framework.modules.serviceDiscovery.createClient('rolesv2');
136
+ await new RolesApi(client, user.localAccountId).deactivateRole(assignment.id);
137
+ onChange({ ...assignment, isActive: false, activeTo: null });
138
+ resetForm();
139
+ } catch (cause) {
140
+ setError(cause instanceof Error ? cause.message : 'Failed to deactivate role.');
141
+ } finally {
142
+ setIsPending(false);
143
+ }
144
+ };
145
+
146
+ /**
147
+ * Opens the activation form or deactivates the current assignment.
148
+ * @param event - Switch state emitted by the role control.
149
+ */
150
+ const handleToggle = (event: ChangeEvent<HTMLInputElement>): void => {
151
+ // Turning on an inactive role first opens the required activation form.
152
+ if (event.target.checked && !assignment.isActive) {
153
+ setIsClaiming(true);
154
+ return;
155
+ }
156
+
157
+ // Turning off an active role immediately requests deactivation.
158
+ if (!event.target.checked && assignment.isActive) {
159
+ void deactivateRole();
160
+ }
161
+ };
162
+
163
+ return (
164
+ <Styled.Role $isClaiming={isClaiming}>
165
+ <Styled.Summary>
166
+ <Styled.Indicator $active={assignment.isActive} />
167
+ <Styled.Name>
168
+ <Typography>{assignment.claimableRole.displayName}</Typography>
169
+ <Typography variant="overline">
170
+ {assignment.claimableRole.name}
171
+ {availabilityLabel ? ` (${availabilityLabel})` : ''}
172
+ </Typography>
173
+ </Styled.Name>
174
+ <Switch
175
+ aria-label={`Activate ${assignment.claimableRole.displayName}`}
176
+ checked={assignment.isActive || isClaiming}
177
+ disabled={isPending || isUnavailable}
178
+ onChange={handleToggle}
179
+ />
180
+ </Styled.Summary>
181
+
182
+ {isClaiming && !assignment.isActive && (
183
+ <Styled.Form>
184
+ <Typography variant="body_short">{assignment.claimableRole.description}</Typography>
185
+ <InputWrapper
186
+ labelProps={{ label: 'Duration (hours)' }}
187
+ helperProps={{ text: 'Select how long this role should remain active' }}
188
+ >
189
+ <Slider
190
+ value={durationHours}
191
+ min={1}
192
+ max={8}
193
+ step={1}
194
+ minMaxValues={false}
195
+ onChangeCommitted={(_event, value) => setDurationHours(value[0])}
196
+ />
197
+ </InputWrapper>
198
+ <Textarea
199
+ label="Reason for activation"
200
+ helperText="Enter a descriptive reason for activating this role"
201
+ required
202
+ rows={3}
203
+ value={reason}
204
+ onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setReason(event.target.value)}
205
+ />
206
+ {error && (
207
+ <Banner>
208
+ <Banner.Message>{error}</Banner.Message>
209
+ </Banner>
210
+ )}
211
+ <Styled.Actions>
212
+ <Button variant="outlined" disabled={isPending} onClick={resetForm}>
213
+ Cancel
214
+ </Button>
215
+ <Button variant="contained" disabled={isPending} onClick={() => void activateRole()}>
216
+ {isPending ? <CircularProgress size={24} /> : 'Activate'}
217
+ </Button>
218
+ </Styled.Actions>
219
+ </Styled.Form>
220
+ )}
221
+
222
+ {!isClaiming && error && (
223
+ <Banner>
224
+ <Banner.Message>{error}</Banner.Message>
225
+ </Banner>
226
+ )}
227
+ </Styled.Role>
228
+ );
229
+ };
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { RolesApi, type RolesClient } from './RolesApi';
4
+
5
+ /** Creates a typed Roles V2 client mock around a Vitest function. */
6
+ const createClient = (): { client: RolesClient; json: ReturnType<typeof vi.fn> } => {
7
+ const json = vi.fn();
8
+ return { client: { json }, json };
9
+ };
10
+
11
+ describe('RolesApi', () => {
12
+ it('loads consolidated claimable and permanent assignments', async () => {
13
+ const { client, json } = createClient();
14
+ json.mockResolvedValueOnce([{ id: 'claimable' }]).mockResolvedValueOnce([{ id: 'permanent' }]);
15
+ const rolesApi = new RolesApi(client, 'account-id');
16
+
17
+ await expect(rolesApi.getClaimableRoles()).resolves.toEqual([{ id: 'claimable' }]);
18
+ await expect(rolesApi.getPermanentRoles()).resolves.toEqual([{ id: 'permanent' }]);
19
+ expect(json).toHaveBeenNthCalledWith(
20
+ 1,
21
+ '/accounts/account-id/consolidated-claimable-role-assignments',
22
+ );
23
+ expect(json).toHaveBeenNthCalledWith(2, '/accounts/account-id/consolidated-role-assignments');
24
+ });
25
+
26
+ it('sends the activation reason and duration to the claimable assignment', async () => {
27
+ const { client, json } = createClient();
28
+ json.mockResolvedValue({ activeToDate: '2026-08-07T12:00:00Z' });
29
+ const rolesApi = new RolesApi(client, 'account-id');
30
+
31
+ await expect(rolesApi.activateRole('role-id', 'Testing elevated access', 4)).resolves.toEqual({
32
+ activeToDate: '2026-08-07T12:00:00Z',
33
+ });
34
+ expect(json).toHaveBeenCalledWith(
35
+ '/accounts/account-id/claimable-role-assignments/role-id/activate',
36
+ {
37
+ method: 'POST',
38
+ body: JSON.stringify({ reason: 'Testing elevated access', hours: 4 }),
39
+ },
40
+ );
41
+ });
42
+
43
+ it('posts deactivation to the active claimable assignment', async () => {
44
+ const { client, json } = createClient();
45
+ json.mockResolvedValue(undefined);
46
+ const rolesApi = new RolesApi(client, 'account-id');
47
+
48
+ await expect(rolesApi.deactivateRole('role-id')).resolves.toBeUndefined();
49
+ expect(json).toHaveBeenCalledWith(
50
+ '/accounts/account-id/claimable-role-assignments/role-id/deactivate',
51
+ { method: 'POST' },
52
+ );
53
+ });
54
+
55
+ it('propagates service failures to the role UI', async () => {
56
+ const { client, json } = createClient();
57
+ json.mockRejectedValue(new Error('Roles service unavailable'));
58
+ const rolesApi = new RolesApi(client, 'account-id');
59
+
60
+ await expect(rolesApi.getClaimableRoles()).rejects.toThrow('Roles service unavailable');
61
+ });
62
+ });
@@ -0,0 +1,112 @@
1
+ /** Role metadata returned by the Roles V2 consolidated assignment endpoints. */
2
+ export interface RoleDefinition {
3
+ readonly name: string;
4
+ readonly displayName: string;
5
+ readonly description: string;
6
+ }
7
+
8
+ /** Scope metadata attached to a permanent role assignment. */
9
+ export interface RoleScope {
10
+ readonly isGlobal: boolean;
11
+ readonly value: string | null;
12
+ }
13
+
14
+ /** Claimable role assignment state used by the activation controls. */
15
+ export interface ClaimableRoleAssignment {
16
+ readonly id: string;
17
+ readonly claimableRole: RoleDefinition;
18
+ readonly isActive: boolean;
19
+ readonly activeTo: string | null;
20
+ readonly validFrom?: string | null;
21
+ readonly validTo?: string | null;
22
+ }
23
+
24
+ /** Permanent role assignment displayed as a read-only entitlement. */
25
+ export interface PermanentRoleAssignment {
26
+ readonly id: string;
27
+ readonly role: RoleDefinition;
28
+ readonly scope?: RoleScope | null;
29
+ readonly validFrom?: string | null;
30
+ readonly validTo?: string | null;
31
+ }
32
+
33
+ /** Minimal HTTP client contract required by the Roles V2 API. */
34
+ export interface RolesClient {
35
+ json(path: string, init?: RequestInit): Promise<unknown>;
36
+ }
37
+
38
+ interface ActivationResult {
39
+ readonly activeToDate: string;
40
+ }
41
+
42
+ /**
43
+ * Provides the Roles V2 requests used by the dev portal person side sheet.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * const api = new RolesApi(await serviceDiscovery.createClient('rolesv2'), accountId);
48
+ * const roles = await api.getClaimableRoles();
49
+ * ```
50
+ */
51
+ export class RolesApi {
52
+ #client: RolesClient;
53
+ #accountId: string;
54
+
55
+ /**
56
+ * Creates a Roles V2 API client scoped to one Fusion account.
57
+ * @param client - Service discovery HTTP client for the `rolesv2` service.
58
+ * @param accountId - Local Fusion account identifier for the signed-in user.
59
+ */
60
+ constructor(client: RolesClient, accountId: string) {
61
+ this.#client = client;
62
+ this.#accountId = accountId;
63
+ }
64
+
65
+ /**
66
+ * Fetches consolidated claimable role assignments.
67
+ * @returns Claimable assignments for the scoped Fusion account.
68
+ */
69
+ async getClaimableRoles(): Promise<ClaimableRoleAssignment[]> {
70
+ return (await this.#client.json(
71
+ `/accounts/${this.#accountId}/consolidated-claimable-role-assignments`,
72
+ )) as ClaimableRoleAssignment[];
73
+ }
74
+
75
+ /**
76
+ * Fetches consolidated permanent role assignments.
77
+ * @returns Permanent assignments for the scoped Fusion account.
78
+ */
79
+ async getPermanentRoles(): Promise<PermanentRoleAssignment[]> {
80
+ return (await this.#client.json(
81
+ `/accounts/${this.#accountId}/consolidated-role-assignments`,
82
+ )) as PermanentRoleAssignment[];
83
+ }
84
+
85
+ /**
86
+ * Activates a claimable role for a bounded duration.
87
+ * @param roleId - Claimable assignment identifier.
88
+ * @param reason - User-provided reason for privilege elevation.
89
+ * @param hours - Requested activation duration in hours.
90
+ * @returns Activation metadata containing the server-calculated expiry.
91
+ */
92
+ async activateRole(roleId: string, reason: string, hours: number): Promise<ActivationResult> {
93
+ return (await this.#client.json(
94
+ `/accounts/${this.#accountId}/claimable-role-assignments/${roleId}/activate`,
95
+ {
96
+ method: 'POST',
97
+ body: JSON.stringify({ reason, hours }),
98
+ },
99
+ )) as ActivationResult;
100
+ }
101
+
102
+ /**
103
+ * Deactivates an active claimable role.
104
+ * @param roleId - Claimable assignment identifier.
105
+ */
106
+ async deactivateRole(roleId: string): Promise<void> {
107
+ await this.#client.json(
108
+ `/accounts/${this.#accountId}/claimable-role-assignments/${roleId}/deactivate`,
109
+ { method: 'POST' },
110
+ );
111
+ }
112
+ }