@vibexnpm/talkx 8.6.0 → 8.7.0

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": "@vibexnpm/talkx",
3
- "version": "8.6.0",
3
+ "version": "8.7.0",
4
4
  "type": "module",
5
5
  "description": "TalkFlow SDK - Chat & WebRTC unified client",
6
6
  "main": "dist/talkflow-sdk.umd.js",
@@ -39,6 +39,7 @@ class TalkFlowClient extends EventEmitter {
39
39
  * // 1단계: 로그인 — 클라이언트 생성 «전» 에 부른다. ck/projectId/JWT 를 한 번에 받는다.
40
40
  * // const { clientKey, projectId, accessToken } = await TalkFlowClient.loginUser({
41
41
  * // serverUrl, email, password });
42
+ * // (회사 계정이 둘 이상이면 먼저 workspaceSelectionRequired 결과 → projectId 를 실어 다시 부른다)
42
43
  *
43
44
  * // 2단계: 받은 값으로 SDK 초기화
44
45
  * const client = new TalkFlowClient({
@@ -310,21 +311,28 @@ class TalkFlowClient extends EventEmitter {
310
311
  * <p>Server Key 도 고객사 BFF 도 필요 없다. 응답의
311
312
  * {@code clientKey}/{@code projectId}/{@code accessToken} 이 그대로 SDK 초기화 입력이다.</p>
312
313
  *
313
- * <p><b>회사 선택 단계가 없다</b>: 로그인 키가 전역 유일이라 이메일 하나로 회원과 회사가
314
- * 함께 정해진다. 퇴사하면 그 키가 풀려 다른 회사에서 같은 이메일을 다시 쓸 수 있다.</p>
314
+ * <p><b>회사 선택</b>: 같은 이메일이 여러 회사에 계정을 가질 수 있다(다른 회사의 게스트 등). 비밀번호가 맞는 계정이
315
+ * 둘 이상이면 토큰 없이 {@code {workspaceSelectionRequired: true, workspaces: [{projectId, companyName, companyRole}]}}
316
+ * 를 돌려준다 — 저장된 세션은 그대로다. 고른 {@code projectId} 를 실어 같은 이메일·비밀번호로 다시 부른다.
317
+ * 계정이 하나면 지금처럼 바로 로그인된다.</p>
315
318
  *
316
319
  * @param {Object} options
317
320
  * @param {string} options.serverUrl - 채팅 서버 base URL
318
321
  * @param {string} options.email
319
322
  * @param {string} options.password
320
- * @returns {Promise<Object>} `{accessToken, tokenType, expiresIn, projectId, clientKey, user}`
323
+ * @param {string} [options.projectId] - 회사 선택 응답에서 고른 회사
324
+ * @returns {Promise<Object>} `{accessToken, tokenType, expiresIn, projectId, clientKey, user}` 또는 회사 선택 결과
321
325
  *
322
326
  * @example
323
- * const login = await TalkFlowClient.loginUser({
327
+ * let login = await TalkFlowClient.loginUser({
324
328
  * serverUrl: 'https://chat.apiorbit.net',
325
329
  * email: 'dan@example.com',
326
330
  * password: '********'
327
331
  * });
332
+ * if (login.workspaceSelectionRequired) {
333
+ * const picked = await showWorkspacePicker(login.workspaces); // 화면에서 고른 회사
334
+ * login = await TalkFlowClient.loginUser({ serverUrl, email, password, projectId: picked.projectId });
335
+ * }
328
336
  *
329
337
  * const client = new TalkFlowClient({
330
338
  * apiKey: login.clientKey,
@@ -333,13 +341,13 @@ class TalkFlowClient extends EventEmitter {
333
341
  * });
334
342
  * await client.connect();
335
343
  */
336
- static async loginUser({ serverUrl, email, password }) {
344
+ static async loginUser({ serverUrl, email, password, projectId }) {
337
345
  if (!serverUrl) throw new Error('serverUrl is required');
338
346
  if (!email) throw new Error('email is required');
339
347
  if (!password) throw new Error('password is required');
340
348
 
341
349
  // 로그인은 키를 «받기 위해» 부르는 경로라 X-API-KEY/X-PROJECT-ID 가 없다(서버에서 필터 제외).
342
- return AuthSession.forServer(serverUrl).login({ email, password });
350
+ return AuthSession.forServer(serverUrl).login({ email, password, projectId });
343
351
  }
344
352
 
345
353
  /**
@@ -348,16 +356,19 @@ class TalkFlowClient extends EventEmitter {
348
356
  * <p>이미 클라이언트가 있는 상태의 재로그인·계정 전환용이다. 최초 진입은 클라이언트 생성 전에
349
357
  * 부르는 정적 {@link TalkFlowClient.loginUser} 를 쓴다 — 그쪽이 {@code clientKey} 까지 준다.</p>
350
358
  *
359
+ * <p>회사 선택 응답이면 토큰을 설정하지 않고 그 결과를 돌려준다 — 정적 {@link TalkFlowClient.loginUser} 와 같다.</p>
360
+ *
351
361
  * @param {Object} credentials
352
362
  * @param {string} credentials.email
353
363
  * @param {string} credentials.password
354
- * @returns {Promise<Object>} 로그인 응답
364
+ * @param {string} [credentials.projectId] - 회사 선택 응답에서 고른 회사
365
+ * @returns {Promise<Object>} 로그인 응답 또는 회사 선택 결과
355
366
  */
356
- async loginUser({ email, password }) {
367
+ async loginUser({ email, password, projectId }) {
357
368
  if (!email || !password) throw new Error('email and password are required');
358
369
  this._enableRefreshSession();
359
370
  await this._authReady;
360
- return this.authSession.login({ email, password });
371
+ return this.authSession.login({ email, password, projectId });
361
372
  }
362
373
 
363
374
  /**
@@ -4,6 +4,21 @@ const sessions = new Map();
4
4
  const TERMINAL_CODES = new Set(['REFRESH_INVALID', 'REFRESH_REUSED', 'SESSION_REVOKED', 'ACCOUNT_BLOCKED', 'LOGIN_FAILED']);
5
5
  export const isTerminalAuthError = error => TERMINAL_CODES.has(error?.response?.code || error?.code);
6
6
 
7
+ /**
8
+ * 로그인 회사 선택 결과 — 토큰이 없다. 목록이 비었거나 회사 id 가 없는 응답은 계약 위반이라 인증 불가로 끊는다(빈 선택 화면을 띄우지 않게).
9
+ */
10
+ function workspaceSelection(data) {
11
+ const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
12
+ if (workspaces.length === 0 || workspaces.some(workspace => !workspace?.projectId)) {
13
+ const error = new Error('Invalid authentication response'); error.code = 'AUTH_UNAVAILABLE'; throw error;
14
+ }
15
+ return {
16
+ workspaceSelectionRequired: true,
17
+ workspaces: workspaces.map(({ projectId, companyName, companyRole }) =>
18
+ ({ projectId, companyName: companyName ?? null, companyRole }))
19
+ };
20
+ }
21
+
7
22
  export function tokenClaims(token) {
8
23
  try {
9
24
  const value = token.replace(/^Bearer\s+/i, '').split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
@@ -240,13 +255,23 @@ export default class AuthSession extends EventEmitter {
240
255
  } finally { clearTimeout(timeout); }
241
256
  }
242
257
 
243
- async login({ email, password }) {
258
+ /**
259
+ * 같은 이메일로 여러 회사 계정이 있고 비밀번호가 둘 이상 맞으면 서버는 토큰 없이 회사 목록만 준다. 그 응답은
260
+ * {@link workspaceSelection} 결과로 돌려준다. 저장소·인증 상태·이전 세션은 건드리지 않는다 — 서버도 이 단계에서는
261
+ * 이전 세션을 끊지 않는다. 호출자는 고른 {@code projectId} 를 실어 같은 자격으로 다시 부른다(서버가 비밀번호를 다시 검증).
262
+ */
263
+ async login({ email, password, projectId }) {
244
264
  const generation = ++this.generation;
245
265
  return this._locked(async () => {
246
266
  const previous = this._readMarker();
247
267
  this._writeMarker(previous);
248
268
  if (previous.pendingRefreshToken) await this._request('/v1/auth/signout', { refreshToken: previous.pendingRefreshToken, deviceId: previous.deviceId });
249
- const data = await this._request('/api/v1/auth/login', { email, password, previousRefreshToken: previous.refreshToken });
269
+ const data = await this._request('/api/v1/auth/login',
270
+ { email, password, previousRefreshToken: previous.refreshToken, ...(projectId ? { projectId } : {}) });
271
+ if (data?.workspaceSelectionRequired === true) {
272
+ if (generation !== this.generation) throw this._cancelled();
273
+ return workspaceSelection(data);
274
+ }
250
275
  if (!data?.refreshToken) {
251
276
  const error = new Error('Server does not support the refresh session contract');
252
277
  error.code = 'AUTH_PROTOCOL_UNSUPPORTED';
@@ -2511,8 +2511,8 @@ class ChatClient extends EventEmitter {
2511
2511
  * @param {string} roomId - 채팅방 ID
2512
2512
  * @returns {Promise<void>}
2513
2513
  *
2514
- * @fires memberJoined - { roomId, members: [{ userId, nickname, profileUrl }], participantCount, timestamp }
2515
- * @fires memberLeft - { roomId, members: [{ userId, nickname, profileUrl }], participantCount, timestamp }
2514
+ * @fires memberJoined - { roomId, members: [{ userId, nickname, profileUrl, companyRole? }], participantCount, timestamp }
2515
+ * @fires memberLeft - { roomId, members: [{ userId, nickname, profileUrl, companyRole? }], participantCount, timestamp }
2516
2516
  */
2517
2517
  async subscribeRoom(roomId) {
2518
2518
  if (this.subscribedRooms.has(roomId)) {
package/src/index.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * import TalkFlowClient from '@talkflow/sdk';
15
15
  *
16
16
  * // 1단계: 로그인. 클라이언트 생성 «전» 에 부른다 — ck/projectId/JWT 를 한 번에 받는다.
17
+ * // 회사 계정이 둘 이상이면 먼저 { workspaceSelectionRequired, workspaces } 가 온다 → projectId 를 실어 다시 부른다.
17
18
  * const { clientKey, projectId, accessToken } = await TalkFlowClient.loginUser({
18
19
  * serverUrl: 'https://chat.apiorbit.net',
19
20
  * email: 'me@example.com',
@@ -1,4 +1,4 @@
1
- import TalkFlowClient, { AuthState, LoginResult } from '../index';
1
+ import TalkFlowClient, { AuthState, LoginResult, WorkspaceSelection, CompanyRole, ParticipantResponse, RoomListMemberInfo } from '../index';
2
2
  const client = new TalkFlowClient({ serverUrl: 'https://api.example.com', sessionMode: 'refresh' });
3
3
  const restored: Promise<LoginResult | null> = client.restoreSession();
4
4
  const state: AuthState = client.getAuthState();
@@ -6,3 +6,25 @@ const signedIn: boolean = client.isLoggedIn();
6
6
  const access: Promise<string> = client.getAccessToken({ forceRefresh: true, rejectedToken: 'old' });
7
7
  client.on('authStateChanged', ({ state }) => { const typed: AuthState = state; void typed; });
8
8
  void [restored, state, signedIn, access];
9
+
10
+ // 회사 선택 — 결과를 workspaceSelectionRequired 로 가른 뒤에만 토큰 칸을 쓴다.
11
+ async function loginWithSelection(): Promise<LoginResult> {
12
+ const first = await TalkFlowClient.loginUser({ serverUrl: 'https://api.example.com', email: 'a@b.c', password: 'pw' });
13
+ if (first.workspaceSelectionRequired) {
14
+ const picked: WorkspaceSelection['workspaces'][number] = first.workspaces[0];
15
+ const role: CompanyRole = picked.companyRole;
16
+ void role;
17
+ const second = await client.loginUser({ email: 'a@b.c', password: 'pw', projectId: picked.projectId });
18
+ if (second.workspaceSelectionRequired) throw new Error('unexpected');
19
+ return second;
20
+ }
21
+ const key: string = first.clientKey;
22
+ void key;
23
+ return first;
24
+ }
25
+ // @ts-expect-error 가르기 전에는 clientKey 가 없을 수 있다
26
+ TalkFlowClient.loginUser({ serverUrl: 'x', email: 'a', password: 'b' }).then(result => result.clientKey.length);
27
+ const guestRole: CompanyRole = 'GUEST';
28
+ const participantRole: ParticipantResponse['companyRole'] = 'GUEST';
29
+ const memberRole: RoomListMemberInfo['companyRole'] = undefined;
30
+ void [loginWithSelection, guestRole, participantRole, memberRole];
package/types/index.d.ts CHANGED
@@ -1044,14 +1044,20 @@ export interface DecisionResponse {
1044
1044
  optionId: string;
1045
1045
  }
1046
1046
 
1047
+ /**
1048
+ * 회사 역할. `GUEST` 는 회사 밖에서 초대받아 그 회사 계정을 가진 사람이다 — 초대받은 방에서 채팅만 하고 PM·AI 버튼·조직도·
1049
+ * 방 만들기·초대 등은 서버가 막는다(403 `GUEST_FORBIDDEN`). 화면 노출 판단용이며 인가 근거는 아니다.
1050
+ */
1051
+ export type CompanyRole = 'PARTNER' | 'MEMBER' | 'GUEST';
1052
+
1047
1053
  /** 로그인한 회원. `userId` 는 메시지 발신자 식별과 같은 값이다. */
1048
1054
  export interface LoginMember {
1049
1055
  userId: string;
1050
1056
  email?: string | null;
1051
1057
  nickname?: string | null;
1052
1058
  profileUrl?: string | null;
1053
- /** 회사 역할 — 클라이언트가 파트너 화면 노출을 결정한다. 인가 근거는 아니다(서버가 재확인). */
1054
- companyRole: 'PARTNER' | 'MEMBER';
1059
+ /** 회사 역할 — 클라이언트가 파트너·게스트 화면 노출을 결정한다. 인가 근거는 아니다(서버가 재확인). */
1060
+ companyRole: CompanyRole;
1055
1061
  preferredLanguage?: string | null;
1056
1062
  /**
1057
1063
  * 초기 비밀번호를 아직 안 바꿨는가 — `true` 면 **비밀번호 변경 화면으로 보내라**.
@@ -1063,11 +1069,11 @@ export interface LoginMember {
1063
1069
  }
1064
1070
 
1065
1071
  /**
1066
- * `TalkFlowClient.loginUser` 응답.
1067
- *
1068
- * 회사 선택 단계가 없다 — 로그인 키가 전역 유일이라 이메일 하나로 회원과 회사가 함께 정해진다.
1072
+ * `TalkFlowClient.loginUser` 의 로그인 성공 응답. 회사 선택이 필요하면 대신 {@link WorkspaceSelection} 이 온다 —
1073
+ * `workspaceSelectionRequired` 로 가른다.
1069
1074
  */
1070
1075
  export interface LoginResult {
1076
+ workspaceSelectionRequired?: false;
1071
1077
  /** SDK가 저장·회전하는 갱신 자격. 직접 API를 쓰는 경우에만 응답에서 사용한다. */
1072
1078
  refreshToken?: string;
1073
1079
  /** 서버 현재 시각 및 refresh 만료 시각, Unix epoch 밀리초. */
@@ -1083,6 +1089,23 @@ export interface LoginResult {
1083
1089
  user: LoginMember;
1084
1090
  }
1085
1091
 
1092
+ /** 로그인 회사 선택지 하나. */
1093
+ export interface Workspace {
1094
+ projectId: string;
1095
+ companyName: string | null;
1096
+ /** 그 회사에서의 역할 — 선택 화면에 «게스트» 표시를 붙이는 데 쓴다. */
1097
+ companyRole: CompanyRole;
1098
+ }
1099
+
1100
+ /**
1101
+ * 로그인 회사 선택 결과 — 같은 이메일의 여러 회사 계정에서 비밀번호가 둘 이상 맞았다. **토큰이 없고 저장된 세션은 그대로다.**
1102
+ * 고른 `projectId` 를 실어 같은 이메일·비밀번호로 `loginUser` 를 다시 부른다.
1103
+ */
1104
+ export interface WorkspaceSelection {
1105
+ workspaceSelectionRequired: true;
1106
+ workspaces: Workspace[];
1107
+ }
1108
+
1086
1109
  /** 회사 조직도의 회원 — 초대 UI 에 필요한 것만. */
1087
1110
  export interface DirectoryMember {
1088
1111
  userId: string;
@@ -1124,7 +1147,7 @@ export interface MyProfile {
1124
1147
  phone?: string | null;
1125
1148
  profileUrl?: string | null;
1126
1149
  /** 회사 역할 — 방 역할(ParticipantResponse.role)과 다른 축이다. */
1127
- companyRole: 'PARTNER' | 'MEMBER';
1150
+ companyRole: CompanyRole;
1128
1151
  orgNodeId?: string | null;
1129
1152
  orgName?: string | null;
1130
1153
  preferredLanguage?: string | null;
@@ -1170,6 +1193,10 @@ export interface ParticipantResponse {
1170
1193
  role: 'OWNER' | 'ADMIN' | 'MEMBER' | 'ASSISTANT';
1171
1194
  /** 참가 시각(epoch millis). */
1172
1195
  joinedAt: number;
1196
+ /**
1197
+ * 회사 역할 — 게스트 배지(`GUEST`)용. 방 역할({@link role})과 다른 축이다. PM(ASSISTANT)·정보를 못 찾은 참여자는 없다.
1198
+ */
1199
+ companyRole?: CompanyRole;
1173
1200
  }
1174
1201
 
1175
1202
  /** 채팅방 last message preview. */
@@ -1280,6 +1307,8 @@ export interface RoomListMemberInfo {
1280
1307
  userId: string;
1281
1308
  nickname?: string | null;
1282
1309
  profileUrl?: string | null;
1310
+ /** 회사 역할 — 방금 들어온 게스트에도 다시 읽지 않고 배지를 그린다. PM·역할을 모르는 경로는 없다. */
1311
+ companyRole?: CompanyRole;
1283
1312
  }
1284
1313
 
1285
1314
  export interface RoomListUpdateEvent {
@@ -1934,7 +1963,7 @@ export interface ChatMessageReceivedPayload {
1934
1963
 
1935
1964
  export interface MemberChangePayload {
1936
1965
  roomId: string;
1937
- members: Array<{ userId: string; nickname?: string; profileUrl?: string | null }>;
1966
+ members: Array<{ userId: string; nickname?: string; profileUrl?: string | null; companyRole?: CompanyRole }>;
1938
1967
  participantCount: number;
1939
1968
  timestamp?: number;
1940
1969
  }
@@ -2602,16 +2631,18 @@ export default class TalkFlowClient implements SdkEventEmitter<TalkFlowClientEve
2602
2631
  /**
2603
2632
  * 회원 로그인 — 클라이언트 생성 **전에** 호출한다. Server Key 도 고객사 BFF 도 필요 없다.
2604
2633
  * 응답의 `clientKey`/`projectId`/`accessToken` 이 그대로 SDK 초기화 입력이다.
2605
- * 회사 선택 단계가 없다 — 로그인 키가 전역 유일이다.
2634
+ * 같은 이메일로 비밀번호가 맞는 회사 계정이 둘 이상이면 {@link WorkspaceSelection} 이 온다 — 고른 `projectId` 로 다시 부른다.
2606
2635
  */
2607
2636
  static loginUser(options: {
2608
2637
  serverUrl: string;
2609
2638
  email: string;
2610
2639
  password: string;
2611
- }): Promise<LoginResult>;
2640
+ /** 회사 선택 응답에서 고른 회사. */
2641
+ projectId?: string;
2642
+ }): Promise<LoginResult | WorkspaceSelection>;
2612
2643
 
2613
- /** 회원 로그인 (인스턴스) — 재로그인·계정 전환용. 성공 시 토큰을 자동 설정한다. */
2614
- loginUser(credentials: { email: string; password: string }): Promise<LoginResult>;
2644
+ /** 회원 로그인 (인스턴스) — 재로그인·계정 전환용. 성공 시 토큰을 자동 설정한다. 회사 선택 결과면 토큰을 설정하지 않는다. */
2645
+ loginUser(credentials: { email: string; password: string; projectId?: string }): Promise<LoginResult | WorkspaceSelection>;
2615
2646
 
2616
2647
  /** localStorage의 refresh로 복구. 세션이 없으면 null, 네트워크/서버 장애는 reject. */
2617
2648
  static restoreSession(options: { serverUrl: string }): Promise<LoginResult | null>;