@gravito/satellite-membership 0.1.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/.dockerignore +8 -0
- package/.env.example +19 -0
- package/ARCHITECTURE.md +14 -0
- package/CHANGELOG.md +14 -0
- package/Dockerfile +25 -0
- package/README.md +112 -0
- package/WHITEPAPER.md +20 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +1121 -0
- package/docs/EXTENDING.md +99 -0
- package/docs/PASSKEYS.md +78 -0
- package/locales/en.json +30 -0
- package/locales/zh-TW.json +30 -0
- package/package.json +35 -0
- package/src/Application/DTOs/MemberDTO.ts +34 -0
- package/src/Application/Mail/ForgotPasswordMail.ts +42 -0
- package/src/Application/Mail/MemberLevelChangedMail.ts +41 -0
- package/src/Application/Mail/WelcomeMail.ts +45 -0
- package/src/Application/Services/PasskeysService.ts +198 -0
- package/src/Application/UseCases/ForgotPassword.ts +34 -0
- package/src/Application/UseCases/LoginMember.ts +64 -0
- package/src/Application/UseCases/RegisterMember.ts +65 -0
- package/src/Application/UseCases/ResetPassword.ts +30 -0
- package/src/Application/UseCases/UpdateMemberLevel.ts +47 -0
- package/src/Application/UseCases/UpdateSettings.ts +81 -0
- package/src/Application/UseCases/VerifyEmail.ts +23 -0
- package/src/Domain/Contracts/IMemberPasskeyRepository.ts +8 -0
- package/src/Domain/Contracts/IMemberRepository.ts +11 -0
- package/src/Domain/Entities/Member.ts +219 -0
- package/src/Domain/Entities/MemberPasskey.ts +97 -0
- package/src/Infrastructure/Auth/SentinelMemberProvider.ts +52 -0
- package/src/Infrastructure/Persistence/AtlasMemberPasskeyRepository.ts +63 -0
- package/src/Infrastructure/Persistence/AtlasMemberRepository.ts +91 -0
- package/src/Infrastructure/Persistence/Migrations/20250101_create_members_table.ts +30 -0
- package/src/Infrastructure/Persistence/Migrations/20250102_create_member_passkeys_table.ts +25 -0
- package/src/Interface/Http/Controllers/PasskeyController.ts +98 -0
- package/src/Interface/Http/Middleware/VerifySingleDevice.ts +51 -0
- package/src/index.ts +234 -0
- package/src/manifest.json +15 -0
- package/tests/PasskeysService.test.ts +113 -0
- package/tests/email-integration.test.ts +161 -0
- package/tests/grand-review.ts +176 -0
- package/tests/integration.test.ts +75 -0
- package/tests/member.test.ts +47 -0
- package/tests/unit.test.ts +7 -0
- package/tests/update-settings.test.ts +101 -0
- package/tsconfig.json +26 -0
- package/views/emails/level_changed.html +35 -0
- package/views/emails/reset_password.html +34 -0
- package/views/emails/welcome.html +31 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import { UseCase } from '@gravito/enterprise'
|
|
3
|
+
import type { IMemberRepository } from '../../Domain/Contracts/IMemberRepository'
|
|
4
|
+
import type { MemberDTO } from '../DTOs/MemberDTO'
|
|
5
|
+
import { MemberMapper } from '../DTOs/MemberDTO'
|
|
6
|
+
|
|
7
|
+
export interface LoginMemberInput {
|
|
8
|
+
email: string
|
|
9
|
+
passwordPlain: string
|
|
10
|
+
remember?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Login Member Use Case
|
|
15
|
+
*
|
|
16
|
+
* Uses Gravito Sentinel for robust authentication.
|
|
17
|
+
* Supports multi-device login restriction toggle via config.
|
|
18
|
+
*/
|
|
19
|
+
export class LoginMember extends UseCase<LoginMemberInput, MemberDTO> {
|
|
20
|
+
constructor(
|
|
21
|
+
private repository: IMemberRepository,
|
|
22
|
+
private core: PlanetCore
|
|
23
|
+
) {
|
|
24
|
+
super()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async execute(input: LoginMemberInput): Promise<MemberDTO> {
|
|
28
|
+
// 使用核心的 auth 管理器 (Sentinel) 進行驗證
|
|
29
|
+
const auth = this.core.container.make<any>('auth')
|
|
30
|
+
|
|
31
|
+
const result = await auth.guard('web').attempt(
|
|
32
|
+
{
|
|
33
|
+
email: input.email,
|
|
34
|
+
password: input.passwordPlain,
|
|
35
|
+
},
|
|
36
|
+
input.remember || false
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if (!result) {
|
|
40
|
+
throw new Error('Invalid credentials')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const user = await auth.guard('web').user()
|
|
44
|
+
|
|
45
|
+
// 多設備限制邏輯
|
|
46
|
+
const singleDevice = this.core.config.get('membership.auth.single_device', false)
|
|
47
|
+
if (singleDevice) {
|
|
48
|
+
const session = this.core.container.make<any>('session')
|
|
49
|
+
if (session) {
|
|
50
|
+
// 獲取目前生成的 Session ID
|
|
51
|
+
const sessionId = session.id()
|
|
52
|
+
|
|
53
|
+
// 更新會員實體中的 Session ID
|
|
54
|
+
const member = await this.repository.findById(user.id)
|
|
55
|
+
if (member) {
|
|
56
|
+
member.bindSession(sessionId)
|
|
57
|
+
await this.repository.save(member)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return MemberMapper.toDTO(user)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import { UseCase } from '@gravito/enterprise'
|
|
3
|
+
import type { IMemberRepository } from '../../Domain/Contracts/IMemberRepository'
|
|
4
|
+
import { Member } from '../../Domain/Entities/Member'
|
|
5
|
+
import type { MemberDTO } from '../DTOs/MemberDTO'
|
|
6
|
+
import { MemberMapper } from '../DTOs/MemberDTO'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Input for the member registration process
|
|
10
|
+
*/
|
|
11
|
+
export interface RegisterMemberInput {
|
|
12
|
+
name: string
|
|
13
|
+
email: string
|
|
14
|
+
passwordPlain: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register Member Use Case
|
|
19
|
+
*
|
|
20
|
+
* Coordinates the registration of a new member including validation,
|
|
21
|
+
* password hashing, and domain event triggering.
|
|
22
|
+
*/
|
|
23
|
+
export class RegisterMember extends UseCase<RegisterMemberInput, MemberDTO> {
|
|
24
|
+
constructor(
|
|
25
|
+
private repository: IMemberRepository,
|
|
26
|
+
private core: PlanetCore
|
|
27
|
+
) {
|
|
28
|
+
super()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Execute the registration flow
|
|
33
|
+
*/
|
|
34
|
+
async execute(input: RegisterMemberInput): Promise<MemberDTO> {
|
|
35
|
+
// 1. Check if email already exists
|
|
36
|
+
const existing = await this.repository.findByEmail(input.email)
|
|
37
|
+
if (existing) {
|
|
38
|
+
// Use i18n from core services
|
|
39
|
+
const i18n = this.core.container.make<any>('i18n')
|
|
40
|
+
throw new Error(i18n?.t('membership.errors.member_exists') || 'Member already exists')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. Hash password using the core hasher
|
|
44
|
+
const passwordHash = await this.core.hasher.make(input.passwordPlain)
|
|
45
|
+
|
|
46
|
+
// 3. Create domain entity
|
|
47
|
+
const member = Member.create(crypto.randomUUID(), input.name, input.email, passwordHash)
|
|
48
|
+
|
|
49
|
+
// 4. Save to persistence
|
|
50
|
+
await this.repository.save(member)
|
|
51
|
+
|
|
52
|
+
// 5. Send Verification Email (via Signal hook)
|
|
53
|
+
await this.core.hooks.doAction('membership:send-verification', {
|
|
54
|
+
email: member.email,
|
|
55
|
+
token: member.verificationToken,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// 6. Trigger hooks for general extensions
|
|
59
|
+
await this.core.hooks.doAction('membership:registered', {
|
|
60
|
+
member: MemberMapper.toDTO(member),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return MemberMapper.toDTO(member)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import { UseCase } from '@gravito/enterprise'
|
|
3
|
+
import type { IMemberRepository } from '../../Domain/Contracts/IMemberRepository'
|
|
4
|
+
|
|
5
|
+
export interface ResetPasswordInput {
|
|
6
|
+
token: string
|
|
7
|
+
newPasswordPlain: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ResetPassword extends UseCase<ResetPasswordInput, void> {
|
|
11
|
+
constructor(
|
|
12
|
+
private repository: IMemberRepository,
|
|
13
|
+
private core: PlanetCore
|
|
14
|
+
) {
|
|
15
|
+
super()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async execute(input: ResetPasswordInput): Promise<void> {
|
|
19
|
+
const member = await this.repository.findByResetToken(input.token)
|
|
20
|
+
|
|
21
|
+
if (!member || !member.passwordResetExpiresAt || member.passwordResetExpiresAt < new Date()) {
|
|
22
|
+
throw new Error('Invalid or expired reset token')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const newHash = await this.core.hasher.make(input.newPasswordPlain)
|
|
26
|
+
member.resetPassword(newHash)
|
|
27
|
+
|
|
28
|
+
await this.repository.save(member)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import { UseCase } from '@gravito/enterprise'
|
|
3
|
+
import type { IMemberRepository } from '../../Domain/Contracts/IMemberRepository'
|
|
4
|
+
import type { MemberDTO } from '../DTOs/MemberDTO'
|
|
5
|
+
import { MemberMapper } from '../DTOs/MemberDTO'
|
|
6
|
+
|
|
7
|
+
export interface UpdateLevelInput {
|
|
8
|
+
memberId: string
|
|
9
|
+
newLevel: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Update Member Level Use Case
|
|
14
|
+
*
|
|
15
|
+
* Typically used by administrative systems or loyalty programs to promote members.
|
|
16
|
+
*/
|
|
17
|
+
export class UpdateMemberLevel extends UseCase<UpdateLevelInput, MemberDTO> {
|
|
18
|
+
constructor(
|
|
19
|
+
private repository: IMemberRepository,
|
|
20
|
+
private core: PlanetCore
|
|
21
|
+
) {
|
|
22
|
+
super()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async execute(input: UpdateLevelInput): Promise<MemberDTO> {
|
|
26
|
+
const member = await this.repository.findById(input.memberId)
|
|
27
|
+
|
|
28
|
+
if (!member) {
|
|
29
|
+
throw new Error('Member not found')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const oldLevel = member.level
|
|
33
|
+
member.changeLevel(input.newLevel)
|
|
34
|
+
|
|
35
|
+
await this.repository.save(member)
|
|
36
|
+
|
|
37
|
+
// Trigger hook for tier change (e.g., to send congratulations or update discounts)
|
|
38
|
+
await this.core.hooks.doAction('membership:level-changed', {
|
|
39
|
+
memberId: member.id,
|
|
40
|
+
email: member.email,
|
|
41
|
+
oldLevel,
|
|
42
|
+
newLevel: input.newLevel,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return MemberMapper.toDTO(member)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import { UseCase } from '@gravito/enterprise'
|
|
3
|
+
import type { IMemberRepository } from '../../Domain/Contracts/IMemberRepository'
|
|
4
|
+
import type { MemberDTO } from '../DTOs/MemberDTO'
|
|
5
|
+
import { MemberMapper } from '../DTOs/MemberDTO'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input for updating member settings
|
|
9
|
+
*/
|
|
10
|
+
export interface UpdateSettingsInput {
|
|
11
|
+
memberId: string
|
|
12
|
+
name?: string
|
|
13
|
+
currentPassword?: string
|
|
14
|
+
newPassword?: string
|
|
15
|
+
/** Custom metadata fields provided by extensions or specific apps */
|
|
16
|
+
metadata?: Record<string, any>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Update Settings Use Case
|
|
21
|
+
*
|
|
22
|
+
* Handles profile updates, password changes, and dynamic metadata enrichment.
|
|
23
|
+
*/
|
|
24
|
+
export class UpdateSettings extends UseCase<UpdateSettingsInput, MemberDTO> {
|
|
25
|
+
constructor(
|
|
26
|
+
private repository: IMemberRepository,
|
|
27
|
+
private core: PlanetCore
|
|
28
|
+
) {
|
|
29
|
+
super()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Execute the update flow
|
|
34
|
+
*/
|
|
35
|
+
async execute(input: UpdateSettingsInput): Promise<MemberDTO> {
|
|
36
|
+
const member = await this.repository.findById(input.memberId)
|
|
37
|
+
|
|
38
|
+
if (!member) {
|
|
39
|
+
throw new Error('Member not found')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 1. Handle Profile Update
|
|
43
|
+
if (input.name) {
|
|
44
|
+
member.updateProfile(input.name)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Handle Password Change (Secure Flow)
|
|
48
|
+
if (input.newPassword) {
|
|
49
|
+
if (!input.currentPassword) {
|
|
50
|
+
throw new Error('Current password is required to set a new password')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isCurrentValid = await this.core.hasher.check(
|
|
54
|
+
input.currentPassword,
|
|
55
|
+
member.passwordHash
|
|
56
|
+
)
|
|
57
|
+
if (!isCurrentValid) {
|
|
58
|
+
throw new Error('Invalid current password')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const newHash = await this.core.hasher.make(input.newPassword)
|
|
62
|
+
member.changePassword(newHash)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Handle Dynamic Metadata (Custom Fields)
|
|
66
|
+
if (input.metadata) {
|
|
67
|
+
member.updateMetadata(input.metadata)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 4. Persist changes
|
|
71
|
+
await this.repository.save(member)
|
|
72
|
+
|
|
73
|
+
// 5. Trigger update hook
|
|
74
|
+
await this.core.hooks.doAction('membership:updated', {
|
|
75
|
+
member: MemberMapper.toDTO(member),
|
|
76
|
+
updatedFields: Object.keys(input).filter((k) => k !== 'memberId' && k !== 'currentPassword'),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return MemberMapper.toDTO(member)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { UseCase } from '@gravito/enterprise'
|
|
2
|
+
import type { IMemberRepository } from '../../Domain/Contracts/IMemberRepository'
|
|
3
|
+
|
|
4
|
+
export interface VerifyEmailInput {
|
|
5
|
+
token: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class VerifyEmail extends UseCase<VerifyEmailInput, void> {
|
|
9
|
+
constructor(private repository: IMemberRepository) {
|
|
10
|
+
super()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async execute(input: VerifyEmailInput): Promise<void> {
|
|
14
|
+
const member = await this.repository.findByVerificationToken(input.token)
|
|
15
|
+
|
|
16
|
+
if (!member) {
|
|
17
|
+
throw new Error('Invalid verification token')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
member.verifyEmail()
|
|
21
|
+
await this.repository.save(member)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Repository } from '@gravito/enterprise'
|
|
2
|
+
import type { MemberPasskey } from '../Entities/MemberPasskey'
|
|
3
|
+
|
|
4
|
+
export interface IMemberPasskeyRepository extends Repository<MemberPasskey, string> {
|
|
5
|
+
findByMemberId(memberId: string): Promise<MemberPasskey[]>
|
|
6
|
+
findByCredentialId(credentialId: string): Promise<MemberPasskey | null>
|
|
7
|
+
deleteByCredentialId(credentialId: string): Promise<void>
|
|
8
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Repository } from '@gravito/enterprise'
|
|
2
|
+
import type { Member } from '../Entities/Member'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Member Repository Contract
|
|
6
|
+
*/
|
|
7
|
+
export interface IMemberRepository extends Repository<Member, string> {
|
|
8
|
+
findByEmail(email: string): Promise<Member | null>
|
|
9
|
+
findByVerificationToken(token: string): Promise<Member | null>
|
|
10
|
+
findByResetToken(token: string): Promise<Member | null>
|
|
11
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Entity } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Member status enumeration
|
|
5
|
+
*/
|
|
6
|
+
export enum MemberStatus {
|
|
7
|
+
ACTIVE = 'active',
|
|
8
|
+
PENDING = 'pending',
|
|
9
|
+
SUSPENDED = 'suspended',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Properties for the Member entity
|
|
14
|
+
*/
|
|
15
|
+
export interface MemberProps {
|
|
16
|
+
email: string
|
|
17
|
+
name: string
|
|
18
|
+
passwordHash: string
|
|
19
|
+
status: MemberStatus
|
|
20
|
+
roles: string[]
|
|
21
|
+
verificationToken?: string
|
|
22
|
+
emailVerifiedAt?: Date
|
|
23
|
+
passwordResetToken?: string
|
|
24
|
+
passwordResetExpiresAt?: Date
|
|
25
|
+
currentSessionId?: string
|
|
26
|
+
rememberToken?: string
|
|
27
|
+
createdAt: Date
|
|
28
|
+
updatedAt: Date
|
|
29
|
+
/** Flexible metadata for extensions */
|
|
30
|
+
metadata?: Record<string, any>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Member Domain Entity
|
|
35
|
+
*/
|
|
36
|
+
export class Member extends Entity<string> {
|
|
37
|
+
private constructor(
|
|
38
|
+
id: string,
|
|
39
|
+
private props: MemberProps
|
|
40
|
+
) {
|
|
41
|
+
super(id)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static create(id: string, name: string, email: string, passwordHash: string): Member {
|
|
45
|
+
return new Member(id, {
|
|
46
|
+
name,
|
|
47
|
+
email,
|
|
48
|
+
passwordHash,
|
|
49
|
+
status: MemberStatus.PENDING,
|
|
50
|
+
roles: ['member'],
|
|
51
|
+
verificationToken: crypto.randomUUID(),
|
|
52
|
+
createdAt: new Date(),
|
|
53
|
+
updatedAt: new Date(),
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static reconstitute(id: string, props: MemberProps): Member {
|
|
58
|
+
return new Member(id, props)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Getters
|
|
62
|
+
get name() {
|
|
63
|
+
return this.props.name
|
|
64
|
+
}
|
|
65
|
+
get email() {
|
|
66
|
+
return this.props.email
|
|
67
|
+
}
|
|
68
|
+
get status() {
|
|
69
|
+
return this.props.status
|
|
70
|
+
}
|
|
71
|
+
get roles() {
|
|
72
|
+
return this.props.roles
|
|
73
|
+
}
|
|
74
|
+
get passwordHash() {
|
|
75
|
+
return this.props.passwordHash
|
|
76
|
+
}
|
|
77
|
+
get createdAt() {
|
|
78
|
+
return this.props.createdAt
|
|
79
|
+
}
|
|
80
|
+
get emailVerifiedAt() {
|
|
81
|
+
return this.props.emailVerifiedAt
|
|
82
|
+
}
|
|
83
|
+
get verificationToken() {
|
|
84
|
+
return this.props.verificationToken
|
|
85
|
+
}
|
|
86
|
+
get passwordResetToken() {
|
|
87
|
+
return this.props.passwordResetToken
|
|
88
|
+
}
|
|
89
|
+
get passwordResetExpiresAt() {
|
|
90
|
+
return this.props.passwordResetExpiresAt
|
|
91
|
+
}
|
|
92
|
+
get currentSessionId() {
|
|
93
|
+
return this.props.currentSessionId
|
|
94
|
+
}
|
|
95
|
+
get rememberToken() {
|
|
96
|
+
return this.props.rememberToken
|
|
97
|
+
}
|
|
98
|
+
get metadata() {
|
|
99
|
+
return this.props.metadata || {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Authenticatable implementation
|
|
103
|
+
getAuthIdentifier(): string {
|
|
104
|
+
return this.id
|
|
105
|
+
}
|
|
106
|
+
getAuthPassword(): string {
|
|
107
|
+
return this.props.passwordHash
|
|
108
|
+
}
|
|
109
|
+
getRememberToken(): string | null {
|
|
110
|
+
return this.props.rememberToken || null
|
|
111
|
+
}
|
|
112
|
+
setRememberToken(token: string): void {
|
|
113
|
+
this.props.rememberToken = token
|
|
114
|
+
this.props.updatedAt = new Date()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Bind a session ID to this member to restrict multi-device login.
|
|
119
|
+
*/
|
|
120
|
+
public bindSession(sessionId: string): void {
|
|
121
|
+
this.props.currentSessionId = sessionId
|
|
122
|
+
this.props.updatedAt = new Date()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get current membership level (from metadata or default)
|
|
127
|
+
*/
|
|
128
|
+
get level(): string {
|
|
129
|
+
return this.metadata.level || 'standard'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if member has a specific role
|
|
134
|
+
*/
|
|
135
|
+
public hasRole(role: string): boolean {
|
|
136
|
+
return this.props.roles.includes(role)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Assign a new role
|
|
141
|
+
*/
|
|
142
|
+
public addRole(role: string): void {
|
|
143
|
+
if (!this.hasRole(role)) {
|
|
144
|
+
this.props.roles.push(role)
|
|
145
|
+
this.props.updatedAt = new Date()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Remove a role
|
|
151
|
+
*/
|
|
152
|
+
public removeRole(role: string): void {
|
|
153
|
+
this.props.roles = this.props.roles.filter((r) => r !== role)
|
|
154
|
+
this.props.updatedAt = new Date()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Update membership level
|
|
159
|
+
*/
|
|
160
|
+
public changeLevel(newLevel: string): void {
|
|
161
|
+
this.updateMetadata({ level: newLevel })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Update core profile information
|
|
166
|
+
*/
|
|
167
|
+
public updateProfile(name: string): void {
|
|
168
|
+
this.props.name = name
|
|
169
|
+
this.props.updatedAt = new Date()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Update member password
|
|
174
|
+
*/
|
|
175
|
+
public changePassword(newPasswordHash: string): void {
|
|
176
|
+
this.props.passwordHash = newPasswordHash
|
|
177
|
+
this.props.updatedAt = new Date()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Merges new metadata into existing metadata
|
|
182
|
+
*/
|
|
183
|
+
public updateMetadata(data: Record<string, any>): void {
|
|
184
|
+
this.props.metadata = {
|
|
185
|
+
...(this.props.metadata || {}),
|
|
186
|
+
...data,
|
|
187
|
+
}
|
|
188
|
+
this.props.updatedAt = new Date()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Mark email as verified
|
|
193
|
+
*/
|
|
194
|
+
public verifyEmail(): void {
|
|
195
|
+
this.props.emailVerifiedAt = new Date()
|
|
196
|
+
this.props.status = MemberStatus.ACTIVE
|
|
197
|
+
this.props.verificationToken = undefined
|
|
198
|
+
this.props.updatedAt = new Date()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Generate a password reset token
|
|
203
|
+
*/
|
|
204
|
+
public generatePasswordResetToken(): void {
|
|
205
|
+
this.props.passwordResetToken = crypto.randomUUID()
|
|
206
|
+
this.props.passwordResetExpiresAt = new Date(Date.now() + 3600000)
|
|
207
|
+
this.props.updatedAt = new Date()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Complete password reset
|
|
212
|
+
*/
|
|
213
|
+
public resetPassword(newPasswordHash: string): void {
|
|
214
|
+
this.props.passwordHash = newPasswordHash
|
|
215
|
+
this.props.passwordResetToken = undefined
|
|
216
|
+
this.props.passwordResetExpiresAt = undefined
|
|
217
|
+
this.props.updatedAt = new Date()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Entity } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export interface MemberPasskeyProps {
|
|
4
|
+
memberId: string
|
|
5
|
+
credentialId: string
|
|
6
|
+
publicKey: string
|
|
7
|
+
counter: number
|
|
8
|
+
transports?: string[]
|
|
9
|
+
displayName?: string
|
|
10
|
+
createdAt: Date
|
|
11
|
+
updatedAt: Date
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class MemberPasskey extends Entity<string> {
|
|
15
|
+
private constructor(
|
|
16
|
+
id: string,
|
|
17
|
+
private props: MemberPasskeyProps
|
|
18
|
+
) {
|
|
19
|
+
super(id)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static create(params: {
|
|
23
|
+
memberId: string
|
|
24
|
+
credentialId: string
|
|
25
|
+
publicKey: string
|
|
26
|
+
transports?: string[]
|
|
27
|
+
displayName?: string
|
|
28
|
+
counter?: number
|
|
29
|
+
}): MemberPasskey {
|
|
30
|
+
const now = new Date()
|
|
31
|
+
return new MemberPasskey(crypto.randomUUID(), {
|
|
32
|
+
memberId: params.memberId,
|
|
33
|
+
credentialId: params.credentialId,
|
|
34
|
+
publicKey: params.publicKey,
|
|
35
|
+
counter: params.counter ?? 0,
|
|
36
|
+
transports: params.transports,
|
|
37
|
+
displayName: params.displayName,
|
|
38
|
+
createdAt: now,
|
|
39
|
+
updatedAt: now,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static reconstitute(id: string, props: MemberPasskeyProps): MemberPasskey {
|
|
44
|
+
return new MemberPasskey(id, props)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get memberId(): string {
|
|
48
|
+
return this.props.memberId
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get credentialId(): string {
|
|
52
|
+
return this.props.credentialId
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get publicKey(): string {
|
|
56
|
+
return this.props.publicKey
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get counter(): number {
|
|
60
|
+
return this.props.counter
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get transports(): string[] | undefined {
|
|
64
|
+
return this.props.transports
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get displayName(): string | undefined {
|
|
68
|
+
return this.props.displayName
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get createdAt(): Date {
|
|
72
|
+
return this.props.createdAt
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get updatedAt(): Date {
|
|
76
|
+
return this.props.updatedAt
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public updateCounter(next: number): void {
|
|
80
|
+
this.props.counter = next
|
|
81
|
+
this.props.updatedAt = new Date()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
toRecord(): Record<string, unknown> {
|
|
85
|
+
return {
|
|
86
|
+
id: this.id,
|
|
87
|
+
member_id: this.memberId,
|
|
88
|
+
credential_id: this.credentialId,
|
|
89
|
+
public_key: this.publicKey,
|
|
90
|
+
counter: this.counter,
|
|
91
|
+
transports: this.transports ? JSON.stringify(this.transports) : null,
|
|
92
|
+
display_name: this.displayName,
|
|
93
|
+
created_at: this.createdAt,
|
|
94
|
+
updated_at: this.updatedAt,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Authenticatable, UserProvider } from '@gravito/sentinel'
|
|
2
|
+
import type { IMemberRepository } from '../../Domain/Contracts/IMemberRepository'
|
|
3
|
+
import type { Member } from '../../Domain/Entities/Member'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sentinel Member Provider
|
|
7
|
+
*
|
|
8
|
+
* Adapts our Member Repository to Gravito Sentinel's Auth system.
|
|
9
|
+
*/
|
|
10
|
+
export class SentinelMemberProvider implements UserProvider {
|
|
11
|
+
constructor(private repository: IMemberRepository) {}
|
|
12
|
+
|
|
13
|
+
async retrieveById(id: string): Promise<Authenticatable | null> {
|
|
14
|
+
const member = await this.repository.findById(id)
|
|
15
|
+
return member ? this.toAuthenticatable(member) : null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async retrieveByToken(id: string, token: string): Promise<Authenticatable | null> {
|
|
19
|
+
const member = await this.repository.findById(id)
|
|
20
|
+
if (member && member.getRememberToken() === token) {
|
|
21
|
+
return member
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async updateRememberToken(user: Authenticatable, token: string): Promise<void> {
|
|
27
|
+
const member = await this.repository.findById(user.getAuthIdentifier() as string)
|
|
28
|
+
if (member) {
|
|
29
|
+
member.setRememberToken(token)
|
|
30
|
+
await this.repository.save(member)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async retrieveByCredentials(credentials: Record<string, any>): Promise<Authenticatable | null> {
|
|
35
|
+
if (!credentials.email) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
return await this.repository.findByEmail(credentials.email)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async validateCredentials(
|
|
42
|
+
_user: Authenticatable,
|
|
43
|
+
_credentials: Record<string, any>
|
|
44
|
+
): Promise<boolean> {
|
|
45
|
+
// Note: In a real app, you might check if account is active here
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private toAuthenticatable(member: Member): Authenticatable {
|
|
50
|
+
return member
|
|
51
|
+
}
|
|
52
|
+
}
|