@meith/accounts 0.30.1 → 0.32.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": "@meith/accounts",
3
- "version": "0.30.1",
3
+ "version": "0.32.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "hash-wasm": "^4.12.0",
23
- "@meith/i18n": "0.30.1"
23
+ "@meith/i18n": "0.32.0",
24
+ "@meith/subscriptions": "0.32.0"
24
25
  }
25
26
  }
@@ -33,6 +33,12 @@ export type FederationOutcome =
33
33
  readonly login: LoginResult
34
34
  readonly created: boolean
35
35
  }
36
+ | {
37
+ readonly status: 'second-factor'
38
+ readonly account: AccountRecord
39
+ readonly token: string
40
+ readonly expiresAt: Date
41
+ }
36
42
  | {
37
43
  readonly status: 'pending'
38
44
  readonly account: AccountRecord
@@ -67,9 +73,8 @@ export class FederationService {
67
73
  )
68
74
  }
69
75
 
70
- const login = await this.identity.startSessionFor(account, context)
71
76
  await this.identities.markUsed(linked.id, this.now())
72
- return { status: 'signed-in', account, login, created: false }
77
+ return this.begin(account, context, false)
73
78
  }
74
79
 
75
80
  const email = normalisedEmail(input.profile)
@@ -85,13 +90,12 @@ export class FederationService {
85
90
  )
86
91
  }
87
92
 
88
- const login = await this.identity.startSessionFor(existing, context)
89
93
  await this.link({
90
94
  userId: existing.id,
91
95
  provider: input.provider,
92
96
  profile: input.profile,
93
97
  })
94
- return { status: 'signed-in', account: existing, login, created: false }
98
+ return this.begin(existing, context, false)
95
99
  }
96
100
 
97
101
  if (email === null) {
@@ -126,12 +130,26 @@ export class FederationService {
126
130
  }
127
131
  }
128
132
 
129
- return {
130
- status: 'signed-in',
131
- account: provisioned.account,
132
- login: await this.identity.startSessionFor(provisioned.account, context),
133
- created: true,
133
+ return this.begin(provisioned.account, context, true)
134
+ }
135
+
136
+ private async begin(
137
+ account: AccountRecord,
138
+ context: RequestContext,
139
+ created: boolean,
140
+ ): Promise<FederationOutcome> {
141
+ const outcome = await this.identity.beginSessionFor(account, context)
142
+
143
+ if (outcome.status === 'second-factor') {
144
+ return {
145
+ status: 'second-factor',
146
+ account,
147
+ token: outcome.token,
148
+ expiresAt: outcome.expiresAt,
149
+ }
134
150
  }
151
+
152
+ return { status: 'signed-in', account, login: outcome.login, created }
135
153
  }
136
154
 
137
155
  async linkToViewer(input: {
@@ -1,5 +1,6 @@
1
1
  import { ValidationError } from '@meith/core'
2
2
  import { msg, normaliseLocale } from '@meith/i18n'
3
+ import { parseSubscriptionMode, type SubscriptionMode } from '@meith/subscriptions'
3
4
 
4
5
  import { foldIdentifier } from './case-fold'
5
6
  import { hashPassword, needsRehash, verifyPassword } from './crypto/password'
@@ -33,6 +34,9 @@ export interface MemberSettings {
33
34
  readonly bio: string | null
34
35
  readonly displayGroupId: number | null
35
36
  readonly massMailOptInAt: Date | null
37
+ readonly boardDigestCadence: string
38
+ readonly autoWatchOwnThreads: SubscriptionMode
39
+ readonly autoWatchRepliedThreads: SubscriptionMode
36
40
  }
37
41
 
38
42
  export interface MemberGroupChoice {
@@ -66,10 +70,17 @@ export interface MemberSettingsRepository {
66
70
  readonly postsPerPage: number | null
67
71
  readonly threadsPerPage: number | null
68
72
  readonly invisible: boolean
73
+ readonly autoWatchOwnThreads: SubscriptionMode
74
+ readonly autoWatchRepliedThreads: SubscriptionMode
69
75
  }): Promise<void>
70
76
 
71
77
  saveMassMailOptIn(input: { readonly userId: number; readonly optIn: boolean }): Promise<void>
72
78
 
79
+ saveBoardDigestCadence(input: {
80
+ readonly userId: number
81
+ readonly cadence: string
82
+ }): Promise<void>
83
+
73
84
  adoptEmail(input: {
74
85
  readonly userId: number
75
86
  readonly email: string
@@ -102,6 +113,12 @@ export function isLocalePreference(value: string): boolean {
102
113
  return value === AUTOMATIC_LOCALE || normaliseLocale(value) !== null
103
114
  }
104
115
 
116
+ function parseAutoWatchPreference(value: string): SubscriptionMode {
117
+ const mode = parseSubscriptionMode(value.trim())
118
+ if (mode === null) throw new ValidationError(msg('error.accounts.auto-watch-preference'))
119
+ return mode
120
+ }
121
+
105
122
  export class MemberSettingsService {
106
123
  private readonly settings: MemberSettingsRepository
107
124
  private readonly accounts: AccountRepository
@@ -201,6 +218,8 @@ export class MemberSettingsService {
201
218
  readonly postsPerPage: string
202
219
  readonly threadsPerPage: string
203
220
  readonly invisible: boolean
221
+ readonly autoWatchOwnThreads: string
222
+ readonly autoWatchRepliedThreads: string
204
223
  }): Promise<void> {
205
224
  const timezone = input.timezone.trim()
206
225
  if (!isTimezonePreference(timezone)) {
@@ -212,6 +231,9 @@ export class MemberSettingsService {
212
231
  throw new ValidationError(msg('error.accounts.language-board-recognises'))
213
232
  }
214
233
 
234
+ const autoWatchOwnThreads = parseAutoWatchPreference(input.autoWatchOwnThreads)
235
+ const autoWatchRepliedThreads = parseAutoWatchPreference(input.autoWatchRepliedThreads)
236
+
215
237
  await this.settings.saveOptions({
216
238
  userId: input.userId,
217
239
  timezone,
@@ -219,9 +241,23 @@ export class MemberSettingsService {
219
241
  postsPerPage: parsePageSize(input.postsPerPage, 'Posts per page'),
220
242
  threadsPerPage: parsePageSize(input.threadsPerPage, 'Threads per page'),
221
243
  invisible: input.invisible,
244
+ autoWatchOwnThreads,
245
+ autoWatchRepliedThreads,
222
246
  })
223
247
  }
224
248
 
249
+ async saveBoardDigestCadence(input: {
250
+ readonly userId: number
251
+ readonly cadence: string
252
+ }): Promise<void> {
253
+ const cadence = input.cadence.trim()
254
+ if (cadence !== 'weekly' && cadence !== 'monthly') {
255
+ throw new ValidationError(msg('error.accounts.digest-cadence-weekly-monthly'))
256
+ }
257
+
258
+ await this.settings.saveBoardDigestCadence({ userId: input.userId, cadence })
259
+ }
260
+
225
261
  async changePassword(input: {
226
262
  readonly userId: number
227
263
  readonly currentPassword: string
package/src/service.ts CHANGED
@@ -505,6 +505,30 @@ export class IdentityService {
505
505
  return this.startSession(account, this.now(), context)
506
506
  }
507
507
 
508
+ async beginSessionFor(
509
+ account: AccountRecord,
510
+ context: RequestContext = {},
511
+ options: { readonly remember?: boolean } = {},
512
+ ): Promise<LoginOutcome> {
513
+ await this.assertSignInAllowed(account)
514
+
515
+ const at = this.now()
516
+ const prefix = prefixOf(context)
517
+ if (prefix !== null) {
518
+ await this.store.accounts.recordLastIpPrefix(account.id, prefix)
519
+ }
520
+
521
+ if (await this.secondFactor?.isEnrolled(account.id)) {
522
+ return {
523
+ status: 'second-factor',
524
+ account,
525
+ ...(await this.holdForSecondFactor(account.id, options.remember === true, at)),
526
+ }
527
+ }
528
+
529
+ return { status: 'signed-in', login: await this.startSession(account, at, context) }
530
+ }
531
+
508
532
  private async assertNotFiltered(subject: BanFilterSubject): Promise<void> {
509
533
  const match = matchBanFilter(await this.banFilters.listAll(), subject)
510
534
  if (match) {