@adatechnology/keycloak-admin 1.0.0-rc.1 → 1.0.0-rc.3

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/README.md CHANGED
@@ -32,9 +32,23 @@ const { id } = await keycloak.createUser({
32
32
  })
33
33
  ```
34
34
 
35
- Operações: `createUser`, `findUserByEmail`, `listUsers`, `updateUser`, `setEnabled`,
35
+ Operações de usuário: `createUser`, `findUserByEmail`, `listUsers`, `updateUser`, `setEnabled`,
36
36
  `updateAttributes`, `deleteUser`, `setPassword`, `setTemporaryPassword`.
37
37
 
38
+ Foto de perfil: `setProfilePicture({ userId, pictureUrl })`. O atributo é `picture`, o nome que o
39
+ OIDC reserva — com um mapeador no realm ele chega ao token, e a tela desenha o avatar sem uma
40
+ consulta por pessoa.
41
+
42
+ ⚠️ O Keycloak **não hospeda imagem**: o valor é uma URL e o arquivo é do produto. Base64 no atributo
43
+ cresce o token até ele parar de caber no cabeçalho, e o sintoma aparece longe — login que funciona
44
+ no navegador e falha no `curl`. A operação lê os atributos antes de gravar, porque o Admin API
45
+ substitui o conjunto: mandar só a foto apagaria `company_id` e o resto.
46
+
47
+ Operações de grupo: `createGroup`, `updateGroup`, `deleteGroup`, `listGroups`, `addUserToGroup`,
48
+ `removeUserFromGroup`. Só o **primeiro nível** — grupo aninhado muda o significado de "pertencer",
49
+ porque quem está no filho herda o pai, e um produto que não modela hierarquia não deve criá-la por
50
+ acidente. A filiação é endereçada pelo **usuário** (`/users/{id}/groups/{id}`), não pelo grupo.
51
+
38
52
  `listUsers({ first, limit, search })` devolve `{ users, hasMore }`. O realm não informa total, então
39
53
  a página pede um registro a mais que o limite e descarta-o: é assim que `hasMore` sai sem uma
40
54
  segunda chamada.
package/dist/index.d.ts CHANGED
@@ -16,8 +16,12 @@ type BuildKeycloakAdminEndpointsParams = {
16
16
  readonly realm: string;
17
17
  };
18
18
  declare function buildKeycloakAdminEndpoints({ baseUrl, realm }: BuildKeycloakAdminEndpointsParams): {
19
+ readonly group: (groupId: string) => string;
20
+ readonly groups: string;
19
21
  readonly token: `${string}/realms/${string}/protocol/openid-connect/token`;
20
22
  readonly user: (userId: string) => string;
23
+ /** O Admin API endereça a filiação pelo usuário, não pelo grupo: é `PUT` para entrar, `DELETE` para sair. */
24
+ readonly userGroup: (userId: string, groupId: string) => string;
21
25
  readonly userPassword: (userId: string) => string;
22
26
  readonly users: string;
23
27
  };
@@ -109,6 +113,20 @@ type SetEnabledParams = {
109
113
  readonly enabled: boolean;
110
114
  readonly userId: string;
111
115
  };
116
+ /**
117
+ * O atributo padrão da foto. `picture` é o nome que o OIDC já reserva para isso, e usá-lo é o que
118
+ * permite a foto chegar ao token por um mapeador do realm em vez de uma consulta extra por tela.
119
+ *
120
+ * ⚠️ O Keycloak **não hospeda imagem**: o valor é uma URL, e quem guarda o arquivo é o produto. Um
121
+ * atributo com base64 dentro cresce o token até ele parar de caber no cabeçalho, e aí o sintoma é
122
+ * login que funciona no navegador e falha no `curl`.
123
+ */
124
+ declare const PROFILE_PICTURE_ATTRIBUTE = "picture";
125
+ type SetProfilePictureParams = {
126
+ /** URL da imagem, ou `undefined` para tirar a foto sem mexer nos outros atributos. */
127
+ readonly pictureUrl: string | undefined;
128
+ readonly userId: string;
129
+ };
112
130
  type UpdateAttributesParams = {
113
131
  readonly attributes: KeycloakUserAttributes;
114
132
  readonly userId: string;
@@ -125,12 +143,57 @@ type SetTemporaryPasswordParams = {
125
143
  readonly password: string;
126
144
  readonly userId: string;
127
145
  };
146
+ /**
147
+ * O grupo do realm. O Keycloak aceita hierarquia (`subGroups`), e este cliente trata só o primeiro
148
+ * nível de propósito: grupo aninhado muda o significado de "pertencer" — quem está no filho herda o
149
+ * pai —, e um produto que não modela hierarquia não deve criá-la por acidente.
150
+ */
151
+ type KeycloakGroup = {
152
+ readonly attributes?: KeycloakUserAttributes;
153
+ readonly id: string;
154
+ readonly name: string;
155
+ readonly path?: string;
156
+ };
157
+ type CreateGroupParams = {
158
+ readonly attributes?: KeycloakUserAttributes;
159
+ readonly name: string;
160
+ };
161
+ type CreateGroupResult = {
162
+ readonly id: string;
163
+ };
164
+ type UpdateGroupParams = {
165
+ readonly groupId: string;
166
+ readonly group: Readonly<Partial<Pick<KeycloakGroup, 'attributes' | 'name'>>>;
167
+ };
168
+ type DeleteGroupParams = {
169
+ readonly groupId: string;
170
+ };
171
+ type ListGroupsParams = {
172
+ readonly first?: number;
173
+ readonly limit?: number;
174
+ readonly search?: string;
175
+ };
176
+ type ListGroupsResult = {
177
+ readonly groups: readonly KeycloakGroup[];
178
+ readonly hasMore: boolean;
179
+ };
180
+ type GroupMembershipParams = {
181
+ readonly groupId: string;
182
+ readonly userId: string;
183
+ };
128
184
  type KeycloakAdminClient = {
185
+ addUserToGroup(params: GroupMembershipParams): Promise<void>;
186
+ createGroup(params: CreateGroupParams): Promise<CreateGroupResult>;
187
+ deleteGroup(params: DeleteGroupParams): Promise<void>;
188
+ listGroups(params?: ListGroupsParams): Promise<ListGroupsResult>;
189
+ removeUserFromGroup(params: GroupMembershipParams): Promise<void>;
190
+ updateGroup(params: UpdateGroupParams): Promise<void>;
129
191
  createUser(params: CreateUserParams): Promise<CreateUserResult>;
130
192
  deleteUser(params: DeleteUserParams): Promise<void>;
131
193
  findUserByEmail(params: FindUserByEmailParams): Promise<KeycloakUser | undefined>;
132
194
  listUsers(params?: ListUsersParams): Promise<ListUsersResult>;
133
195
  setEnabled(params: SetEnabledParams): Promise<void>;
196
+ setProfilePicture(params: SetProfilePictureParams): Promise<void>;
134
197
  setPassword(params: SetPasswordParams): Promise<void>;
135
198
  setTemporaryPassword(params: SetTemporaryPasswordParams): Promise<void>;
136
199
  updateAttributes(params: UpdateAttributesParams): Promise<void>;
@@ -155,4 +218,4 @@ declare function parseKeycloakAdminConfig(value: unknown): KeycloakAdminConfig;
155
218
 
156
219
  declare function createKeycloakAdminClient({ config: rawConfig, fetch: injectedFetch, now, }: CreateKeycloakAdminClientParams): KeycloakAdminClient;
157
220
 
158
- export { type CreateKeycloakAdminClientParams, type CreateUserParams, type CreateUserResult, type DeleteUserParams, type FetchLike, type FindUserByEmailParams, KEYCLOAK_ADMIN_ERROR_CODE, KEYCLOAK_ADMIN_TOKEN_RENEWAL_SKEW_MS, type KeycloakAdminClient, type KeycloakAdminConfig, type KeycloakAdminEndpoints, KeycloakAdminError, type KeycloakAdminErrorCode, type KeycloakAdminErrorContext, type KeycloakAdminErrorParams, type KeycloakPassword, type KeycloakUser, type KeycloakUserAttributes, type ListUsersParams, type ListUsersResult, type SerializedKeycloakAdminError, type SetEnabledParams, type SetPasswordParams, type SetTemporaryPasswordParams, type UpdateAttributesParams, type UpdateUserParams, buildKeycloakAdminEndpoints, createKeycloakAdminClient, isKeycloakAdminError, keycloakAdminConfigSchema, parseKeycloakAdminConfig };
221
+ export { type CreateGroupParams, type CreateGroupResult, type CreateKeycloakAdminClientParams, type CreateUserParams, type CreateUserResult, type DeleteGroupParams, type DeleteUserParams, type FetchLike, type FindUserByEmailParams, type GroupMembershipParams, KEYCLOAK_ADMIN_ERROR_CODE, KEYCLOAK_ADMIN_TOKEN_RENEWAL_SKEW_MS, type KeycloakAdminClient, type KeycloakAdminConfig, type KeycloakAdminEndpoints, KeycloakAdminError, type KeycloakAdminErrorCode, type KeycloakAdminErrorContext, type KeycloakAdminErrorParams, type KeycloakGroup, type KeycloakPassword, type KeycloakUser, type KeycloakUserAttributes, type ListGroupsParams, type ListGroupsResult, type ListUsersParams, type ListUsersResult, PROFILE_PICTURE_ATTRIBUTE, type SerializedKeycloakAdminError, type SetEnabledParams, type SetPasswordParams, type SetProfilePictureParams, type SetTemporaryPasswordParams, type UpdateAttributesParams, type UpdateGroupParams, type UpdateUserParams, buildKeycloakAdminEndpoints, createKeycloakAdminClient, isKeycloakAdminError, keycloakAdminConfigSchema, parseKeycloakAdminConfig };
package/dist/index.js CHANGED
@@ -17,9 +17,14 @@ function buildKeycloakAdminEndpoints({ baseUrl, realm }) {
17
17
  const origin = baseUrl.replace(/\/+$/, "");
18
18
  const encodedRealm = encodeURIComponent(realm);
19
19
  const users = `${origin}/admin/realms/${encodedRealm}/users`;
20
+ const groups = `${origin}/admin/realms/${encodedRealm}/groups`;
20
21
  return {
22
+ group: (groupId) => `${groups}/${encodeURIComponent(groupId)}`,
23
+ groups,
21
24
  token: `${origin}/realms/${encodedRealm}/protocol/openid-connect/token`,
22
25
  user: (userId) => `${users}/${encodeURIComponent(userId)}`,
26
+ /** O Admin API endereça a filiação pelo usuário, não pelo grupo: é `PUT` para entrar, `DELETE` para sair. */
27
+ userGroup: (userId, groupId) => `${users}/${encodeURIComponent(userId)}/groups/${encodeURIComponent(groupId)}`,
23
28
  userPassword: (userId) => `${users}/${encodeURIComponent(userId)}/reset-password`,
24
29
  users
25
30
  };
@@ -182,6 +187,9 @@ function createKeycloakTokenProvider({
182
187
  };
183
188
  }
184
189
 
190
+ // src/keycloak-admin.types.ts
191
+ var PROFILE_PICTURE_ATTRIBUTE = "picture";
192
+
185
193
  // src/keycloak-admin.client.ts
186
194
  var HTTP_CONFLICT = 409;
187
195
  var HTTP_NOT_FOUND = 404;
@@ -195,7 +203,7 @@ function normalizeAttributes(attributes) {
195
203
  Object.entries(attributes).map(([key, value]) => [key, typeof value === "string" ? [value] : [...value]])
196
204
  );
197
205
  }
198
- function userIdFromLocation(response) {
206
+ function idFromLocation(response) {
199
207
  const location = response.headers.get("location");
200
208
  const id = location?.split("/").filter(Boolean).at(-1);
201
209
  if (id === void 0 || id === "") {
@@ -282,7 +290,7 @@ function createKeycloakAdminClient({
282
290
  secrets: [password?.value],
283
291
  url: endpoints.users
284
292
  });
285
- return { id: userIdFromLocation(response) };
293
+ return { id: idFromLocation(response) };
286
294
  },
287
295
  async deleteUser({ userId }) {
288
296
  await adminRequest({ method: "DELETE", url: endpoints.user(userId) });
@@ -297,6 +305,41 @@ function createKeycloakAdminClient({
297
305
  * Pede um a mais que o limite para saber se há próxima página sem uma segunda chamada — o
298
306
  * Keycloak não devolve total, e contar o realm inteiro só para desenhar um botão é caro.
299
307
  */
308
+ async addUserToGroup({ groupId, userId }) {
309
+ await adminRequest({ method: "PUT", url: endpoints.userGroup(userId, groupId) });
310
+ },
311
+ async createGroup({ attributes, name }) {
312
+ const response = await adminRequest({
313
+ body: { ...attributes === void 0 ? {} : { attributes: normalizeAttributes(attributes) }, name },
314
+ method: "POST",
315
+ url: endpoints.groups
316
+ });
317
+ return { id: idFromLocation(response) };
318
+ },
319
+ async deleteGroup({ groupId }) {
320
+ await adminRequest({ method: "DELETE", url: endpoints.group(groupId) });
321
+ },
322
+ /** Mesmo recorte de `listUsers`: pede um a mais que o limite para saber se há próxima página. */
323
+ async listGroups({ first = 0, limit = 100, search } = {}) {
324
+ const query = new URLSearchParams({ first: String(first), max: String(limit + 1) });
325
+ if (search !== void 0 && search !== "") query.set("search", search);
326
+ const response = await adminRequest({ method: "GET", url: `${endpoints.groups}?${query}` });
327
+ const found = await response.json();
328
+ return { groups: found.slice(0, limit), hasMore: found.length > limit };
329
+ },
330
+ async removeUserFromGroup({ groupId, userId }) {
331
+ await adminRequest({ method: "DELETE", url: endpoints.userGroup(userId, groupId) });
332
+ },
333
+ async updateGroup({ group, groupId }) {
334
+ await adminRequest({
335
+ body: {
336
+ ...group.attributes === void 0 ? {} : { attributes: normalizeAttributes(group.attributes) },
337
+ ...group.name === void 0 ? {} : { name: group.name }
338
+ },
339
+ method: "PUT",
340
+ url: endpoints.group(groupId)
341
+ });
342
+ },
300
343
  async listUsers({ first = 0, limit = 100, search } = {}) {
301
344
  const query = new URLSearchParams({ first: String(first), max: String(limit + 1) });
302
345
  if (search !== void 0 && search !== "") query.set("search", search);
@@ -304,6 +347,26 @@ function createKeycloakAdminClient({
304
347
  const found = await response.json();
305
348
  return { hasMore: found.length > limit, users: found.slice(0, limit) };
306
349
  },
350
+ /**
351
+ * A foto é atributo, e o Admin API **substitui o conjunto** quando recebe `attributes`. Por isso
352
+ * esta operação lê o usuário antes: mandar só a foto apagaria `company_id`, `tax_id` e qualquer
353
+ * outro atributo do produto — e o sintoma seria login entrando sem empresa, longe daqui.
354
+ */
355
+ async setProfilePicture({ pictureUrl, userId }) {
356
+ const response = await adminRequest({ method: "GET", url: endpoints.user(userId) });
357
+ const current = await response.json();
358
+ const attributes = { ...current.attributes ?? {} };
359
+ if (pictureUrl === void 0 || pictureUrl === "") {
360
+ delete attributes[PROFILE_PICTURE_ATTRIBUTE];
361
+ } else {
362
+ attributes[PROFILE_PICTURE_ATTRIBUTE] = pictureUrl;
363
+ }
364
+ await adminRequest({
365
+ body: { attributes: normalizeAttributes(attributes) },
366
+ method: "PUT",
367
+ url: endpoints.user(userId)
368
+ });
369
+ },
307
370
  async setEnabled({ enabled, userId }) {
308
371
  await adminRequest({ body: { enabled }, method: "PUT", url: endpoints.user(userId) });
309
372
  },
@@ -327,6 +390,7 @@ export {
327
390
  KEYCLOAK_ADMIN_ERROR_CODE,
328
391
  KEYCLOAK_ADMIN_TOKEN_RENEWAL_SKEW_MS,
329
392
  KeycloakAdminError,
393
+ PROFILE_PICTURE_ATTRIBUTE,
330
394
  buildKeycloakAdminEndpoints,
331
395
  createKeycloakAdminClient,
332
396
  isKeycloakAdminError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/keycloak-admin",
3
- "version": "1.0.0-rc.1",
3
+ "version": "1.0.0-rc.3",
4
4
  "description": "Agnostic Keycloak Admin API client authenticated as a service account (client_credentials)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",