Skip to content

Commit 5d85afe

Browse files
feat(cc-session-tokens): init
Fixes #1360
1 parent b8012b4 commit 5d85afe

10 files changed

+1462
-1
lines changed

src/components/cc-session-tokens/cc-session-tokens.js

+435
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
// prettier-ignore
2+
// @ts-expect-error FIXME: remove when clever-client exports types
3+
import { todo_listSelfTokens as getAllTokens, todo_revokeSelfToken as revokeToken } from '@clevercloud/client/esm/api/v2/user.js';
4+
import { defineSmartComponent } from '../../lib/smart/define-smart-component.js';
5+
import { sendToApi } from '../../lib/send-to-api.js';
6+
import { notifySuccess, notifyError } from '../../lib/notifications.js';
7+
import '../cc-smart-container/cc-smart-container.js';
8+
import './cc-session-tokens.js';
9+
import { i18n } from '../../translations/translation.js';
10+
11+
/**
12+
* @typedef {import('./cc-session-tokens.js').CcSessionTokens} CcSessionTokens
13+
* @typedef {import('./cc-session-tokens.types.js').SessionTokenState} SessionTokenState
14+
* @typedef {import('./cc-session-tokens.types.js').SessionTokensStateLoaded} SessionTokensStateLoaded
15+
* @typedef {import('./cc-session-tokens.types.js').SessionTokensStateRevokingAllTokens} SessionTokensStateRevokingAllTokens
16+
* @typedef {import('./cc-session-tokens.types.js').SessionTokenStateRevoking} SessionTokensStateRevoking
17+
* @typedef {import('./cc-session-tokens.types.js').RawSessionTokenData} RawSessionTokenData
18+
* @typedef {import('../../lib/send-to-api.types.js').ApiConfig} ApiConfig
19+
* @typedef {import('../../lib/smart/smart-component.types.js').OnContextUpdateArgs<CcSessionTokens>} OnContextUpdateArgs
20+
*/
21+
22+
defineSmartComponent({
23+
selector: 'cc-session-tokens',
24+
params: {
25+
apiConfig: { type: Object },
26+
},
27+
/** @param {OnContextUpdateArgs} args */
28+
onContextUpdate({ component, context, onEvent, updateComponent }) {
29+
const { apiConfig } = context;
30+
const api = new Api(apiConfig);
31+
32+
/**
33+
* Updates a single session token
34+
*
35+
* @param {string} tokenId The ID of the token to update
36+
* @param {function(SessionTokenState): void} callback A callback function to execute with the updated token
37+
*/
38+
function updateOneToken(tokenId, callback) {
39+
updateComponent('state', (state) => {
40+
if (!('tokens' in state)) {
41+
return;
42+
}
43+
44+
const sessionTokenToUpdate = state.tokens.find((token) => token.id === tokenId);
45+
46+
if (sessionTokenToUpdate != null) {
47+
callback(sessionTokenToUpdate);
48+
}
49+
});
50+
}
51+
52+
updateComponent('state', { type: 'loading' });
53+
54+
api
55+
.getSessionTokens()
56+
.then((tokens) => {
57+
updateComponent('state', { type: 'loaded', tokens });
58+
})
59+
.catch(() => {
60+
updateComponent('state', { type: 'error' });
61+
});
62+
63+
onEvent(
64+
'cc-session-tokens:revoke-token',
65+
/** @param {string} tokenId */
66+
(tokenId) => {
67+
updateOneToken(tokenId, (sessionTokenState) => {
68+
sessionTokenState.type = 'revoking';
69+
});
70+
71+
api
72+
.revokeSessionToken(tokenId)
73+
.then(() => {
74+
updateComponent(
75+
'state',
76+
/** @param {SessionTokensStateLoaded} state */
77+
(state) => {
78+
state.tokens = state.tokens.filter((token) => token.id !== tokenId);
79+
},
80+
);
81+
notifySuccess(i18n('cc-session-tokens.revoke-token.success'));
82+
})
83+
.catch((error) => {
84+
console.error(error);
85+
updateOneToken(tokenId, (sessionTokenState) => {
86+
sessionTokenState.type = 'idle';
87+
});
88+
notifyError(i18n('cc-session-tokens.revoke-token.error'));
89+
});
90+
},
91+
);
92+
93+
onEvent('cc-session-tokens:revoke-all-tokens', () => {
94+
updateComponent(
95+
'state',
96+
/** @param {SessionTokensStateLoaded} state */
97+
(state) => ({
98+
...state,
99+
type: 'revoking-all',
100+
tokens: state.tokens.map((token) => {
101+
if (token.type !== 'current') {
102+
return { ...token, type: 'revoking' };
103+
}
104+
105+
return token;
106+
}),
107+
}),
108+
);
109+
110+
const tokens = /** @type {SessionTokensStateLoaded} */ (component.state).tokens;
111+
112+
api.revokeAllSessionTokens(tokens).then(({ remainingTokens, errors, revokedTokens }) => {
113+
updateComponent('state', (state) => {
114+
state.type = 'loaded';
115+
116+
/** @type {SessionTokensStateLoaded} */
117+
(state).tokens = remainingTokens.map((token) => ({ ...token, type: 'idle' }));
118+
119+
return state;
120+
});
121+
122+
if (errors.length === 0) {
123+
notifySuccess(i18n('cc-session-tokens.revoke-all-tokens.success'));
124+
} else if (revokedTokens.length > 0) {
125+
notifyError(i18n('cc-session-tokens.revoke-all-tokens.partial-error'));
126+
} else {
127+
notifyError(i18n('cc-session-tokens.revoke-all-tokens.error'));
128+
}
129+
});
130+
});
131+
},
132+
});
133+
134+
class Api {
135+
/** @param {ApiConfig} apiConfig */
136+
constructor(apiConfig) {
137+
this._apiConfig = apiConfig;
138+
}
139+
140+
/**
141+
* Fetches and formats session tokens
142+
*
143+
* @returns {Promise<SessionTokenState[]>} A promise that resolves to an array of formatted session tokens
144+
*/
145+
getSessionTokens() {
146+
return getAllTokens()
147+
.then(sendToApi({ apiConfig: this._apiConfig }))
148+
.then(
149+
/** @param {Array<RawSessionTokenData>} tokens */
150+
(tokens) => {
151+
const filteredTokens = tokens
152+
.filter((token) => token.consumer.key === this._apiConfig.OAUTH_CONSUMER_KEY || token.employeeId != null)
153+
.map((token) => {
154+
const isCurrentSession = token.token === this._apiConfig.API_OAUTH_TOKEN;
155+
/** @type {SessionTokenState} */
156+
const formattedToken = {
157+
type: isCurrentSession ? 'current' : 'idle',
158+
id: token.token,
159+
isCleverTeam: token.employeeId != null,
160+
creationDate: token.creationDate,
161+
expirationDate: token.expirationDate,
162+
lastUsedDate: token.lastUtilisation,
163+
};
164+
return formattedToken;
165+
});
166+
167+
return filteredTokens;
168+
},
169+
);
170+
}
171+
172+
/**
173+
* Revokes a session token
174+
*
175+
* @param {string} tokenId - The ID of the token to revoke
176+
* @returns {Promise<void>} A promise that resolves when the token is revoked
177+
*/
178+
revokeSessionToken(tokenId) {
179+
return revokeToken({ token: tokenId }).then(sendToApi({ apiConfig: this._apiConfig }));
180+
}
181+
182+
/**
183+
* Revokes all session tokens
184+
* We cannot rely on the dedicated API endpoint for this operation because it revokes all tokens, including the current session token and tokens coming from other consumers (oAuth tokens).
185+
* This is why we revoke each token individually and use `Promise.allSettled` to handle errors gracefully.
186+
*
187+
* @param {SessionTokenState[]} allTokens - An array of all session tokens
188+
* @returns {Promise<{ remainingTokens: SessionTokenState[], revokedTokens: string[], errors: any[] }>} A promise that resolves when all tokens are revoked
189+
*/
190+
revokeAllSessionTokens(allTokens) {
191+
const tokensToRevoke = allTokens.filter((token) => token.type === 'revoking');
192+
let errors = [];
193+
/** @type {string[]} */
194+
let revokedTokens = [];
195+
196+
return Promise.allSettled(
197+
tokensToRevoke.map((token) =>
198+
revokeToken({ token: token.id })
199+
.then(sendToApi({ apiConfig: this._apiConfig }))
200+
.then(() => token.id),
201+
),
202+
).then(
203+
/** @param {PromiseSettledResult<string>[]} results */
204+
(results) => {
205+
revokedTokens = results
206+
.filter(
207+
/** @returns {result is PromiseFulfilledResult<string>} */
208+
(result) => result.status === 'fulfilled',
209+
)
210+
.map(({ value }) => value);
211+
errors = results.filter((result) => result.status === 'rejected');
212+
213+
const remainingTokens = allTokens.filter((token) => !revokedTokens.includes(token.id));
214+
215+
return { remainingTokens, revokedTokens, errors };
216+
},
217+
);
218+
}
219+
}
220+
221+
/** TODO: remove - DO NOT REVIEW */
222+
class MockApi {
223+
constructor() {}
224+
225+
/**
226+
* Fetches and formats session tokens
227+
*
228+
* @returns {Promise<SessionTokenState[]>} A promise that resolves to an array of formatted session tokens
229+
*/
230+
getSessionTokens() {
231+
return new Promise((resolve) => {
232+
setTimeout(() => {
233+
// Mock data for session tokens
234+
const mockTokens = [
235+
{
236+
type: 'idle',
237+
id: 'token-123',
238+
isCurrentSession: true,
239+
isCleverTeam: false,
240+
creationDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
241+
expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
242+
lastUsedDate: new Date().toISOString(),
243+
},
244+
{
245+
type: 'idle',
246+
id: 'token-456',
247+
isCurrentSession: false,
248+
isCleverTeam: false,
249+
creationDate: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
250+
expirationDate: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
251+
lastUsedDate: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
252+
},
253+
{
254+
type: 'idle',
255+
id: 'token-789',
256+
isCurrentSession: false,
257+
isCleverTeam: true,
258+
creationDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
259+
expirationDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000 + 6 * 60 * 60 * 1000).toISOString(),
260+
lastUsedDate: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
261+
},
262+
{
263+
type: 'idle',
264+
id: 'token-101',
265+
isCurrentSession: false,
266+
isCleverTeam: false,
267+
creationDate: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(),
268+
expirationDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
269+
lastUsedDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
270+
},
271+
{
272+
type: 'idle',
273+
id: 'token-202',
274+
isCurrentSession: false,
275+
isCleverTeam: false,
276+
creationDate: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
277+
expirationDate: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(),
278+
lastUsedDate: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
279+
},
280+
];
281+
resolve(mockTokens);
282+
}, 2000);
283+
});
284+
}
285+
286+
/**
287+
* Revokes a session token
288+
*
289+
* @param {string} tokenId - The ID of the token to revoke
290+
* @returns {Promise<void>} A promise that resolves when the token is revoked
291+
*/
292+
revokeSessionToken(tokenId) {
293+
return new Promise((resolve) => {
294+
setTimeout(() => {
295+
resolve();
296+
}, 2000);
297+
});
298+
}
299+
300+
/**
301+
* Revokes all session tokens
302+
* We cannot rely on the dedicated API endpoint for this operation because it revokes all tokens, including the current session token and tokens coming from other consumers (oAuth tokens).
303+
* This is why we revoke each token individually and use `Promise.allSettled` to handle errors gracefully.
304+
*
305+
* @param {SessionTokenState[]} allTokens - An array of all session tokens
306+
* @returns {Promise<{ remainingTokens: SessionTokenState[], revokedTokens: string[], errors: any[] }>} A promise that resolves when all tokens are revoked
307+
*/
308+
revokeAllSessionTokens(allTokens) {
309+
const tokensToRevoke = allTokens.filter((token) => token.type === 'revoking');
310+
311+
return new Promise((resolve) => {
312+
setTimeout(() => {
313+
// Simulate successful revocation of all tokens
314+
const revokedTokens = tokensToRevoke.map((token) => token.id);
315+
const errors = [];
316+
const remainingTokens = allTokens.filter((token) => token.type === 'current');
317+
318+
resolve({ remainingTokens, revokedTokens, errors });
319+
}, 2000);
320+
});
321+
}
322+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
kind: '🛠 Profile/<cc-session-tokens>'
3+
title: '💡 Smart'
4+
---
5+
6+
# 💡 Smart `<cc-session-tokens>`
7+
8+
## ℹ️ Details
9+
10+
<table>
11+
<tr><td><strong>Component </strong> <td><a href="https://www.clever-cloud.com/doc/clever-components/?path=/docs/🛠-profile-cc-session-tokens--default-story"><code>&lt;cc-session-tokens&gt;</code></a>
12+
<tr><td><strong>Selector </strong> <td><code>cc-session-tokens</code>
13+
<tr><td><strong>Requires auth</strong> <td>Yes
14+
</table>
15+
16+
## ⚙️ Params
17+
18+
| Name | Type | Details | Default |
19+
|-------------|-------------|---------------------------------------------------------|---------|
20+
| `apiConfig` | `ApiConfig` | Object with API configuration (target host, tokens...) | |
21+
22+
```ts
23+
interface ApiConfig {
24+
API_HOST: string,
25+
API_OAUTH_TOKEN: string,
26+
API_OAUTH_TOKEN_SECRET: string,
27+
OAUTH_CONSUMER_KEY: string,
28+
OAUTH_CONSUMER_SECRET: string,
29+
}
30+
```
31+
32+
## 🌐 API endpoints
33+
34+
| Method | Type | Cache? |
35+
|----------|:------------------------|---------|
36+
| `GET` | `/v2/self/tokens` | Default |
37+
| `POST` | `/v2/self/tokens/revoke`| Default |
38+
39+
```html
40+
<cc-smart-container context='{
41+
"apiConfig": {
42+
API_HOST: "",
43+
API_OAUTH_TOKEN: "",
44+
API_OAUTH_TOKEN_SECRET: "",
45+
OAUTH_CONSUMER_KEY: "",
46+
OAUTH_CONSUMER_SECRET: "",
47+
}
48+
}'>
49+
<cc-session-tokens></cc-session-tokens>
50+
<cc-smart-container>
51+
```

0 commit comments

Comments
 (0)