Skip to content

Commit e2d2345

Browse files
committed
feat: superadmin settings
Signed-off-by: Innei <tukon479@gmail.com>
1 parent a4e506f commit e2d2345

36 files changed

+2911
-428
lines changed

be/apps/core/src/cli/index.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { ResetCliOptions } from './reset-superadmin'
2+
import { handleResetSuperAdminPassword, parseResetCliArgs } from './reset-superadmin'
3+
4+
type CliCommand<TOptions> = {
5+
name: string
6+
parse: (argv: readonly string[]) => TOptions | null
7+
execute: (options: TOptions) => Promise<void>
8+
onError?: (error: unknown) => void
9+
}
10+
11+
const cliCommands: Array<CliCommand<unknown>> = [
12+
{
13+
name: 'reset-superadmin-password',
14+
parse: parseResetCliArgs,
15+
execute: (options) => handleResetSuperAdminPassword(options as ResetCliOptions),
16+
onError: (error) => {
17+
console.error('Superadmin password reset failed', error)
18+
},
19+
},
20+
]
21+
22+
export async function runCliPipeline(argv: readonly string[]): Promise<boolean> {
23+
for (const command of cliCommands) {
24+
const parsedOptions = command.parse(argv)
25+
if (!parsedOptions) {
26+
continue
27+
}
28+
29+
try {
30+
await command.execute(parsedOptions)
31+
} catch (error) {
32+
command.onError?.(error)
33+
// eslint-disable-next-line unicorn/no-process-exit
34+
process.exit(1)
35+
return true
36+
}
37+
38+
return true
39+
}
40+
41+
return false
42+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { randomBytes } from 'node:crypto'
2+
3+
import { authAccounts, authSessions, authUsers, generateId } from '@afilmory/db'
4+
import { env } from '@afilmory/env'
5+
import { eq } from 'drizzle-orm'
6+
7+
import { createConfiguredApp } from '../app.factory'
8+
import { DbAccessor, PgPoolProvider } from '../database/database.provider'
9+
import { logger } from '../helpers/logger.helper'
10+
import { AuthProvider } from '../modules/auth/auth.provider'
11+
import { RedisProvider } from '../redis/redis.provider'
12+
13+
const RESET_FLAG = '--reset-superadmin-password'
14+
const PASSWORD_FLAG = '--password'
15+
const EMAIL_FLAG = '--email'
16+
17+
export interface ResetCliOptions {
18+
password?: string
19+
email?: string
20+
}
21+
22+
export function parseResetCliArgs(args: readonly string[]): ResetCliOptions | null {
23+
const hasResetFlag = args.some((arg) => arg === RESET_FLAG || arg.startsWith(`${RESET_FLAG}=`))
24+
if (!hasResetFlag) {
25+
return null
26+
}
27+
28+
let password: string | undefined
29+
let email: string | undefined
30+
31+
for (let index = 0; index < args.length; index++) {
32+
const arg = args[index]
33+
if (!arg || arg === '--') {
34+
continue
35+
}
36+
37+
if (arg === RESET_FLAG) {
38+
continue
39+
}
40+
41+
if (arg.startsWith(`${RESET_FLAG}=`)) {
42+
const inline = arg.slice(RESET_FLAG.length + 1).trim()
43+
if (inline.length > 0) {
44+
password = inline
45+
}
46+
continue
47+
}
48+
49+
if (arg === PASSWORD_FLAG) {
50+
const value = args[index + 1]
51+
if (!value || value.startsWith('--')) {
52+
throw new Error('Missing value for --password')
53+
}
54+
password = value
55+
index++
56+
continue
57+
}
58+
59+
if (arg.startsWith(`${PASSWORD_FLAG}=`)) {
60+
const value = arg.slice(PASSWORD_FLAG.length + 1).trim()
61+
if (value.length === 0) {
62+
throw new Error('Missing value for --password')
63+
}
64+
password = value
65+
continue
66+
}
67+
68+
if (arg === EMAIL_FLAG) {
69+
const value = args[index + 1]
70+
if (!value || value.startsWith('--')) {
71+
throw new Error('Missing value for --email')
72+
}
73+
email = value
74+
index++
75+
continue
76+
}
77+
78+
if (arg.startsWith(`${EMAIL_FLAG}=`)) {
79+
const value = arg.slice(EMAIL_FLAG.length + 1).trim()
80+
if (value.length === 0) {
81+
throw new Error('Missing value for --email')
82+
}
83+
email = value
84+
}
85+
}
86+
87+
return { password, email }
88+
}
89+
90+
function generateRandomPassword(): string {
91+
return randomBytes(16).toString('base64url')
92+
}
93+
94+
export async function handleResetSuperAdminPassword(options: ResetCliOptions): Promise<void> {
95+
const app = await createConfiguredApp({
96+
globalPrefix: '/api',
97+
})
98+
99+
const container = app.getContainer()
100+
const poolProvider = container.resolve(PgPoolProvider)
101+
const redisProvider = container.resolve(RedisProvider)
102+
const authProvider = container.resolve(AuthProvider)
103+
const dbAccessor = container.resolve(DbAccessor)
104+
105+
try {
106+
const auth = authProvider.getAuth()
107+
const context = await auth.$context
108+
const rawPassword = options.password ?? generateRandomPassword()
109+
const { minPasswordLength, maxPasswordLength } = context.password.config
110+
111+
if (rawPassword.length < minPasswordLength || rawPassword.length > maxPasswordLength) {
112+
throw new Error(`Password must be between ${minPasswordLength} and ${maxPasswordLength} characters.`)
113+
}
114+
115+
const hashedPassword = await context.password.hash(rawPassword)
116+
const db = dbAccessor.get()
117+
118+
const targetEmail = options.email ?? env.DEFAULT_SUPERADMIN_EMAIL
119+
const now = new Date().toISOString()
120+
let resolvedEmail = targetEmail
121+
let revokedSessionsCount = 0
122+
let credentialAccountCreated = false
123+
124+
await db.transaction(async (tx) => {
125+
let superAdmin = await tx.query.authUsers.findFirst({
126+
where: (users, { eq }) => eq(users.email, targetEmail),
127+
})
128+
129+
if (!superAdmin) {
130+
superAdmin = await tx.query.authUsers.findFirst({
131+
where: (users, { eq }) => eq(users.role, 'superadmin'),
132+
})
133+
}
134+
135+
if (!superAdmin) {
136+
const message = options.email
137+
? `No superadmin account found for email "${options.email}"`
138+
: 'No superadmin account found'
139+
throw new Error(message)
140+
}
141+
142+
resolvedEmail = superAdmin.email
143+
144+
const credentialAccount = await tx.query.authAccounts.findFirst({
145+
where: (accounts, { eq, and }) =>
146+
and(eq(accounts.userId, superAdmin.id), eq(accounts.providerId, 'credential')),
147+
})
148+
149+
if (credentialAccount) {
150+
await tx
151+
.update(authAccounts)
152+
.set({ password: hashedPassword, updatedAt: now })
153+
.where(eq(authAccounts.id, credentialAccount.id))
154+
} else {
155+
credentialAccountCreated = true
156+
await tx.insert(authAccounts).values({
157+
id: generateId(),
158+
accountId: superAdmin.id,
159+
providerId: 'credential',
160+
userId: superAdmin.id,
161+
password: hashedPassword,
162+
createdAt: now,
163+
updatedAt: now,
164+
})
165+
}
166+
167+
await tx.update(authUsers).set({ updatedAt: now }).where(eq(authUsers.id, superAdmin.id))
168+
169+
const deletedSessions = await tx
170+
.delete(authSessions)
171+
.where(eq(authSessions.userId, superAdmin.id))
172+
.returning({ id: authSessions.id })
173+
174+
revokedSessionsCount = deletedSessions.length
175+
})
176+
177+
logger.info(
178+
`Superadmin password reset for ${resolvedEmail}. ${credentialAccountCreated ? 'Credential account created.' : 'Credential account updated.'} Revoked ${revokedSessionsCount} sessions.`,
179+
)
180+
181+
process.stdout.write(`Superadmin credentials reset\n email: ${resolvedEmail}\n password: ${rawPassword}\n`)
182+
} finally {
183+
await app.close('cli')
184+
185+
try {
186+
const pool = poolProvider.getPool()
187+
await pool.end()
188+
} catch (error) {
189+
logger.warn(`Failed to close PostgreSQL pool cleanly: ${String(error)}`)
190+
}
191+
192+
try {
193+
const redis = redisProvider.getClient()
194+
redis.disconnect()
195+
} catch (error) {
196+
logger.warn(`Failed to disconnect Redis client cleanly: ${String(error)}`)
197+
}
198+
}
199+
}

be/apps/core/src/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { serve } from '@hono/node-server'
55
import { green } from 'picocolors'
66

77
import { createConfiguredApp } from './app.factory'
8+
import { runCliPipeline } from './cli'
89
import { logger } from './helpers/logger.helper'
910

1011
process.title = 'Hono HTTP Server'
@@ -31,7 +32,16 @@ async function bootstrap() {
3132
)
3233
}
3334

34-
bootstrap().catch((error) => {
35+
async function main() {
36+
const handledByCli = await runCliPipeline(process.argv.slice(2))
37+
if (handledByCli) {
38+
return
39+
}
40+
41+
await bootstrap()
42+
}
43+
44+
main().catch((error) => {
3545
console.error('Application bootstrap failed', error)
3646
// eslint-disable-next-line unicorn/no-process-exit
3747
process.exit(1)

be/apps/core/src/modules/auth/auth.controller.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1+
import { authUsers } from '@afilmory/db'
12
import { Body, ContextParam, Controller, Get, Post, UnauthorizedException } from '@afilmory/framework'
3+
import { BizException, ErrorCode } from 'core/errors'
4+
import { eq } from 'drizzle-orm'
25
import type { Context } from 'hono'
36

7+
import { DbAccessor } from '../../database/database.provider'
48
import { RoleBit, Roles } from '../../guards/roles.decorator'
9+
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
510
import { AuthProvider } from './auth.provider'
611

712
@Controller('auth')
813
export class AuthController {
9-
constructor(private readonly auth: AuthProvider) {}
14+
constructor(
15+
private readonly auth: AuthProvider,
16+
private readonly dbAccessor: DbAccessor,
17+
private readonly superAdminSettings: SuperAdminSettingService,
18+
) {}
1019

1120
@Get('/session')
1221
async getSession(@ContextParam() context: Context) {
@@ -27,6 +36,27 @@ export class AuthController {
2736

2837
@Post('/sign-in/email')
2938
async signInEmail(@ContextParam() context: Context, @Body() body: { email: string; password: string }) {
39+
const email = body.email.trim()
40+
if (email.length === 0) {
41+
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '邮箱不能为空' })
42+
}
43+
const settings = await this.superAdminSettings.getSettings()
44+
if (!settings.localProviderEnabled) {
45+
const db = this.dbAccessor.get()
46+
const [record] = await db
47+
.select({ role: authUsers.role })
48+
.from(authUsers)
49+
.where(eq(authUsers.email, email))
50+
.limit(1)
51+
52+
const isSuperAdmin = record?.role === 'superadmin'
53+
if (!isSuperAdmin) {
54+
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
55+
message: '邮箱密码登录已禁用,请联系管理员开启本地登录。',
56+
})
57+
}
58+
}
59+
3060
const auth = this.auth.getAuth()
3161
const headers = new Headers(context.req.raw.headers)
3262
const tenant = (context as any).var?.tenant
@@ -36,7 +66,7 @@ export class AuthController {
3666
}
3767
const response = await auth.api.signInEmail({
3868
body: {
39-
email: body.email,
69+
email,
4070
password: body.password,
4171
},
4272
asResponse: true,

be/apps/core/src/modules/auth/auth.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Module } from '@afilmory/framework'
22
import { DatabaseModule } from 'core/database/database.module'
33

4+
import { SystemSettingModule } from '../system-setting/system-setting.module'
45
import { AuthConfig } from './auth.config'
56
import { AuthController } from './auth.controller'
67
import { AuthProvider } from './auth.provider'
78

89
@Module({
9-
imports: [DatabaseModule],
10+
imports: [DatabaseModule, SystemSettingModule],
1011
controllers: [AuthController],
1112
providers: [AuthProvider, AuthConfig],
1213
})

be/apps/core/src/modules/index.module.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,30 @@ import { DataSyncModule } from './data-sync/data-sync.module'
1212
import { OnboardingModule } from './onboarding/onboarding.module'
1313
import { PhotoModule } from './photo/photo.module'
1414
import { SettingModule } from './setting/setting.module'
15+
import { SuperAdminModule } from './super-admin/super-admin.module'
16+
import { SystemSettingModule } from './system-setting/system-setting.module'
1517
import { TenantModule } from './tenant/tenant.module'
1618

19+
function createEventModuleOptions(redis: RedisAccessor) {
20+
return {
21+
redisClient: redis.get(),
22+
}
23+
}
24+
1725
@Module({
1826
imports: [
1927
DatabaseModule,
2028
RedisModule,
2129
AuthModule,
2230
SettingModule,
31+
SystemSettingModule,
32+
SuperAdminModule,
2333
OnboardingModule,
2434
PhotoModule,
2535
TenantModule,
2636
DataSyncModule,
2737
EventModule.forRootAsync({
28-
useFactory: async (redis: RedisAccessor) => {
29-
return {
30-
redisClient: redis.get(),
31-
}
32-
},
38+
useFactory: createEventModuleOptions,
3339
inject: [RedisAccessor],
3440
}),
3541
],

0 commit comments

Comments
 (0)