@crombolo/api-management 0.13.1 → 0.14.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": "@crombolo/api-management",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "Umbraco-compatible Management API handlers for crombolo.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.13.1",
32
- "@crombolo/contracts": "0.13.1"
31
+ "@crombolo/core": "0.14.1",
32
+ "@crombolo/contracts": "0.14.1"
33
33
  }
34
34
  }
@@ -62,6 +62,12 @@ export function registerMemberGroupHandlers(
62
62
  name,
63
63
  })
64
64
  if (result.status !== 'ok' || !result.key) return statusResponse(result.status)
65
+ // The toast the backoffice shows, as for every other type it saves.
66
+ ctx.notifications.push({
67
+ message: 'Member group created',
68
+ category: 'Member Group',
69
+ type: 'Success',
70
+ })
65
71
  return created(`${ctx.url.origin}${V1}/member-group/${result.key}`, result.key)
66
72
  })
67
73
 
@@ -69,7 +75,14 @@ export function registerMemberGroupHandlers(
69
75
  const body = ((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
70
76
  const name = typeof body.name === 'string' ? body.name.trim() : ''
71
77
  if (!name) return problemResponse(problemDetails({ title: 'A name is required', status: 400 }))
72
- return statusResponse(await groups.rename(ctx.params.id as string, name))
78
+ const status = await groups.rename(ctx.params.id as string, name)
79
+ if (status === 'ok')
80
+ ctx.notifications.push({
81
+ message: 'Member group saved',
82
+ category: 'Member Group',
83
+ type: 'Success',
84
+ })
85
+ return statusResponse(status)
73
86
  })
74
87
 
75
88
  router.handle('DeleteMemberGroupById', async (ctx) =>
@@ -66,7 +66,7 @@ export function toSaveMember(key: string, body: Record<string, unknown>): SaveMe
66
66
  oldPassword: typeof body.oldPassword === 'string' ? body.oldPassword : null,
67
67
  isApproved: body.isApproved !== false,
68
68
  isLockedOut: body.isLockedOut === true,
69
- isTwoFactorEnabled: body.isTwoFactorEnabled === true,
69
+ isTwoFactorEnabled: body.isTwoFactorEnabled !== false,
70
70
  groupKeys: strings(body.groups),
71
71
  values: readValues(body),
72
72
  variants: readVariants(body),
@@ -459,7 +459,8 @@ function registerUsers(router: ManagementApiRouter, users: UserPort): void {
459
459
  }),
460
460
  )
461
461
 
462
- // No two-factor providers are installed: every provider lookup finds nothing.
462
+ // Story 032: the authenticator provider, when the server offers one; any other
463
+ // provider name finds nothing, as an uninstalled provider does in Umbraco.
463
464
  const noProvider = () =>
464
465
  bad(
465
466
  'Provider not found',
@@ -467,17 +468,57 @@ function registerUsers(router: ManagementApiRouter, users: UserPort): void {
467
468
  'ProviderNameNotFound',
468
469
  404,
469
470
  )
470
- router.handle('GetUserCurrent2fa', () => Response.json([]))
471
- router.handle('GetUserCurrent2faByProviderName', noProvider)
472
- router.handle('PostUserCurrent2faByProviderName', noProvider)
473
- router.handle('DeleteUserCurrent2faByProviderName', noProvider)
471
+ const twoFactor = users.twoFactor
472
+ const provided = (ctx: RequestContext) =>
473
+ twoFactor && ctx.params.providerName === twoFactor.providerName ? twoFactor : undefined
474
+ const refused = (message: string) => bad('Invalid code', message, 'InvalidCode', 400)
475
+ router.handle('GetUserCurrent2fa', async (ctx) =>
476
+ Response.json(twoFactor ? await twoFactor.list(me(ctx).id) : []),
477
+ )
478
+ router.handle('GetUserCurrent2faByProviderName', async (ctx) => {
479
+ const provider = provided(ctx)
480
+ if (!provider) return noProvider()
481
+ const setup = provider.setup(me(ctx).email || me(ctx).userName)
482
+ return setup.ok
483
+ ? Response.json({
484
+ $type: 'TwoFactorAuthInfo',
485
+ secret: setup.secret,
486
+ qrCodeSetupImageUrl: setup.qrCodeSetupImageUrl,
487
+ })
488
+ : bad('Two-factor sign-in is not available', setup.message, 'NotAvailable', 400)
489
+ })
490
+ router.handle('PostUserCurrent2faByProviderName', async (ctx) => {
491
+ const provider = provided(ctx)
492
+ if (!provider) return noProvider()
493
+ const b = await body(ctx)
494
+ const enabled = await provider.enable(me(ctx).id, str(b.code), str(b.secret))
495
+ return enabled.ok
496
+ ? Response.json({ $type: 'TwoFactorRecoveryCodes', recoveryCodes: enabled.recoveryCodes })
497
+ : refused(enabled.message)
498
+ })
499
+ router.handle('DeleteUserCurrent2faByProviderName', async (ctx) => {
500
+ const provider = provided(ctx)
501
+ if (!provider) return noProvider()
502
+ const disabled = await provider.disable(me(ctx).id, ctx.url.searchParams.get('code') ?? '')
503
+ return disabled.ok ? ok() : refused(disabled.message)
504
+ })
474
505
  router.handle('GetUserById2fa', async (ctx) => {
475
506
  const found = await users.get(me(ctx), ctx.params.id as string)
476
- return found.status === 'Success' ? Response.json([]) : status(found.status)
507
+ if (found.status !== 'Success') return status(found.status)
508
+ return Response.json(twoFactor ? await twoFactor.list(ctx.params.id as string) : [])
509
+ })
510
+ router.handle('DeleteUserById2faByProviderName', async (ctx) => {
511
+ const provider = provided(ctx)
512
+ if (!provider) return noProvider()
513
+ // Who may manage this user, by the same rule every user operation follows.
514
+ const found = await users.get(me(ctx), ctx.params.id as string)
515
+ if (found.status !== 'Success') return status(found.status)
516
+ await provider.reset(ctx.params.id as string)
517
+ return ok()
477
518
  })
478
- router.handle('DeleteUserById2faByProviderName', noProvider)
479
- // External backoffice logins arrive with story 031's providers.
480
- router.handle('GetUserCurrentLoginProviders', () => Response.json([]))
519
+ router.handle('GetUserCurrentLoginProviders', async (ctx) =>
520
+ Response.json((await users.loginProviders?.(me(ctx))) ?? []),
521
+ )
481
522
 
482
523
  router.handle('PostSecurityForgotPassword', async (ctx) => {
483
524
  await users.forgotPassword(str((await body(ctx)).email).trim())
@@ -527,11 +568,24 @@ function registerGroups(router: ManagementApiRouter, groups: UserGroupPort): voi
527
568
  router.handle('PostUserGroup', async (ctx) => {
528
569
  const result = await groups.create(me(ctx), readGroup(await body(ctx)))
529
570
  if (result.status !== 'Success' || !result.key) return status(result.status)
571
+ // The toast the backoffice shows, as for every other type it saves.
572
+ ctx.notifications.push({
573
+ message: 'User group created',
574
+ category: 'User Group',
575
+ type: 'Success',
576
+ })
530
577
  return created(`${ctx.url.origin}${V1}/user-group/${result.key}`, result.key)
531
578
  })
532
- router.handle('PutUserGroupById', async (ctx) =>
533
- status(await groups.update(me(ctx), ctx.params.id as string, readGroup(await body(ctx)))),
534
- )
579
+ router.handle('PutUserGroupById', async (ctx) => {
580
+ const result = await groups.update(me(ctx), ctx.params.id as string, readGroup(await body(ctx)))
581
+ if (result === 'Success')
582
+ ctx.notifications.push({
583
+ message: 'User group saved',
584
+ category: 'User Group',
585
+ type: 'Success',
586
+ })
587
+ return status(result)
588
+ })
535
589
  router.handle('DeleteUserGroupById', async (ctx) =>
536
590
  status(await groups.delete(me(ctx), [ctx.params.id as string])),
537
591
  )
@@ -86,7 +86,26 @@ export interface UserUpdateInput {
86
86
 
87
87
  export type Result<T> = { status: 'Success'; value: T } | { status: Exclude<UserStatus, 'Success'> }
88
88
 
89
+ /** Two-factor sign-in for users (story 032): the one authenticator provider. */
90
+ export interface UserTwoFactorPort {
91
+ providerName: string
92
+ list(userKey: string): Promise<Array<{ providerName: string; isEnabledOnUser: boolean }>>
93
+ setup(
94
+ account: string,
95
+ ): { ok: true; secret: string; qrCodeSetupImageUrl: string } | { ok: false; message: string }
96
+ enable(
97
+ userKey: string,
98
+ code: string,
99
+ secret: string,
100
+ ): Promise<{ ok: true; recoveryCodes: string[] } | { ok: false; message: string }>
101
+ disable(userKey: string, code: string): Promise<{ ok: true } | { ok: false; message: string }>
102
+ /** An administrator turning it off for someone who lost their phone. */
103
+ reset(userKey: string): Promise<void>
104
+ }
105
+
89
106
  export interface UserPort {
107
+ /** Two-factor sign-in, when the server offers it. */
108
+ twoFactor?: UserTwoFactorPort
90
109
  list(principal: Principal, skip: number, take: number): Promise<Page<UserResponse>>
91
110
  filter(principal: Principal, query: UserFilter): Promise<Page<UserResponse>>
92
111
  get(principal: Principal, key: string): Promise<Result<UserResponse>>
@@ -138,6 +157,8 @@ export interface UserPort {
138
157
  ): Promise<Array<{ nodeKey: string; permissions: string[] }>>
139
158
  configuration(): ResponseOf<'GetUserConfiguration'>
140
159
  currentConfiguration(): ResponseOf<'GetUserCurrentConfiguration'>
160
+ /** The backoffice identity providers, and which the caller has signed in with (story 031). */
161
+ loginProviders?(principal: Principal): Promise<ResponseOf<'GetUserCurrentLoginProviders'>>
141
162
  passwordConfiguration(): ResponseOf<'GetSecurityConfiguration'>['passwordConfiguration']
142
163
  /** Anonymous: sends a reset link when the address belongs to a user; says nothing either way. */
143
164
  forgotPassword(email: string): Promise<void>