Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

This commit is contained in:
Artem Savchenko
2025-11-17 21:52:25 +07:00
17 changed files with 233 additions and 28 deletions
+7
View File
@@ -245,6 +245,7 @@ jobs:
run: node common/scripts/install-run-rush.js docker
env:
DOCKER_CLI_HINTS: false
DOCKER_BUILDKIT: 1
- name: Configure /etc/hosts
run: |
sudo echo "127.0.0.1 huly.local" | sudo tee -a /etc/hosts
@@ -375,6 +376,7 @@ jobs:
run: node common/scripts/install-run-rush.js docker
env:
DOCKER_CLI_HINTS: false
DOCKER_BUILDKIT: 1
- name: Configure /etc/hosts
run: |
sudo echo "127.0.0.1 huly.local" | sudo tee -a /etc/hosts
@@ -469,6 +471,7 @@ jobs:
run: node common/scripts/install-run-rush.js docker
env:
DOCKER_CLI_HINTS: false
DOCKER_BUILDKIT: 1
- name: Configure /etc/hosts
run: |
sudo echo "127.0.0.1 huly.local" | sudo tee -a /etc/hosts
@@ -550,6 +553,7 @@ jobs:
run: node common/scripts/install-run-rush.js docker
env:
DOCKER_CLI_HINTS: false
DOCKER_BUILDKIT: 1
- name: Configure /etc/hosts
run: |
sudo echo "127.0.0.1 huly.local" | sudo tee -a /etc/hosts
@@ -674,6 +678,8 @@ jobs:
env:
DOCKER_CLI_HINTS: false
DOCKER_EXTRA: --platform=linux/amd64,linux/arm64
DOCKER_BUILDKIT: 1
DOCKER_BUILD_CLEANUP: true
- name: Docker build love-agent
run: |
cd ./services/ai-bot/love-agent
@@ -682,6 +688,7 @@ jobs:
env:
DOCKER_CLI_HINTS: false
DOCKER_EXTRA: --platform=linux/amd64,linux/arm64
DOCKER_BUILDKIT: 1
- name: Login to Docker Hub
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/s') }}
uses: docker/login-action@v3
+27
View File
@@ -2,6 +2,33 @@
version=$(git rev-parse HEAD)
# Check for cleanup flag from environment
cleanup=false
if [ "$DOCKER_BUILD_CLEANUP" = "true" ]; then
cleanup=true
fi
echo "Building version: $version"
docker build -t "$1" -t "$1:$version" ${DOCKER_EXTRA} .
if [ "$cleanup" = true ]; then
echo "Cleaning up build artifacts..."
if [ -d "bundle" ]; then
echo " Removing bundle/"
rm -rf bundle
fi
if [ -d "dist" ]; then
echo " Removing dist/"
rm -rf dist
fi
if [ -d ".rush" ]; then
echo " Removing .rush/"
rm -rf .rush
fi
echo " Size after cleanup: $(du -sh . 2>/dev/null | cut -f1)"
fi
+1 -1
View File
@@ -1,4 +1,4 @@
FROM hardcoreeng/base
FROM hardcoreeng/base-slim:v20250916
WORKDIR /usr/src/app
COPY bundle/bundle.js ./
@@ -45,9 +45,10 @@
export let withProviders: boolean = false
export let subtitle: string | undefined = undefined
export let signUpDisabled = false
export let isLoading: boolean = false
const validate = makeSequential(async function validateAsync (language: string): Promise<boolean> {
if (ignoreInitialValidation) return true
if (ignoreInitialValidation || isLoading) return true
for (const field of fields) {
const v = object[field.name]
@@ -48,24 +48,30 @@
}
let status = OK
let isLoading = false
const action = {
i18n: login.string.LogIn,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await doLogin(object.username, object.password)
status = loginStatus
isLoading = true
try {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await doLogin(object.username, object.password)
status = loginStatus
if (onLogin !== undefined) {
void onLogin(result, status)
} else {
await doLoginNavigate(
result,
(st) => {
status = st
},
navigateUrl
)
if (onLogin !== undefined) {
void onLogin(result, status)
} else {
await doLoginNavigate(
result,
(st) => {
status = st
},
navigateUrl
)
}
} finally {
isLoading = false
}
}
}
@@ -79,6 +85,7 @@
{object}
{action}
{signUpDisabled}
{isLoading}
bottomActions={[recoveryAction]}
ignoreInitialValidation
withProviders
@@ -26,6 +26,10 @@ export class LoginPage {
async login (email: string, password: string): Promise<void> {
await this.inputEmail.fill(email)
await this.inputPassword.fill(password)
// Wait for form validation to complete
await this.page.waitForTimeout(1000)
expect(await this.buttonLogin.isEnabled()).toBe(true)
await this.buttonLogin.click()
}
@@ -256,8 +256,12 @@ async function getValueCollaborators (value: any, attr: AnyAttribute, control: T
if (arrOf._class === core.class.RefTo) {
const to = (arrOf as RefTo<Doc>).to
if (hierarchy.isDerived(to, contact.class.Person)) {
if (!Array.isArray(value)) {
control.ctx.error('Expected array but got non-array value when getting value collaborators', { attr, value })
return []
}
if (value.length === 0) return []
if ((value as any[]).every((it) => it === null)) {
if (value.every((it) => it === null)) {
control.ctx.error('Null-values array of person refs when getting value collaborators', { attr, value })
}
+108 -2
View File
@@ -65,14 +65,16 @@ import {
loginOrSignUpWithProvider,
sendEmail,
addSocialIdBase,
doReleaseSocialId
doReleaseSocialId,
getLastPasswordChangeEvent,
isPasswordChangedSince
} from '../utils'
// eslint-disable-next-line import/no-named-default
import platform, { getMetadata, PlatformError, Severity, Status } from '@hcengineering/platform'
import { decodeTokenVerbose, generateToken, TokenError } from '@hcengineering/server-token'
import { randomBytes } from 'crypto'
import { type AccountDB, AccountEventType, type Workspace } from '../types'
import { type AccountDB, type AccountEvent, AccountEventType, type Workspace } from '../types'
import { accountPlugin } from '../plugin'
// Mock platform with minimum required functionality
@@ -514,6 +516,110 @@ describe('account utils', () => {
expect(verifyPassword(password, hash, salt)).toBe(false)
})
})
describe('getLastPasswordChangeEvent', () => {
const mockDb = {
accountEvent: {
find: jest.fn() as jest.MockedFunction<AccountDB['accountEvent']['find']>
}
} as unknown as AccountDB
beforeEach(() => {
jest.clearAllMocks()
})
test('should return most recent password change event when it exists', async () => {
const accountUuid = 'test-account-uuid' as AccountUuid
const now = Date.now()
const mockEvent: AccountEvent = {
accountUuid,
eventType: AccountEventType.PASSWORD_CHANGED,
time: now
}
;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([mockEvent])
const result = await getLastPasswordChangeEvent(mockDb, accountUuid)
expect(result).toEqual(mockEvent)
expect(mockDb.accountEvent.find).toHaveBeenCalledWith(
{ accountUuid, eventType: AccountEventType.PASSWORD_CHANGED },
{ time: 'descending' },
1
)
})
test('should return null when no password change events exist', async () => {
const accountUuid = 'test-account-uuid' as AccountUuid
;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([])
const result = await getLastPasswordChangeEvent(mockDb, accountUuid)
expect(result).toBeNull()
})
})
describe('isPasswordChangedSince', () => {
const mockDb = {
accountEvent: {
find: jest.fn() as jest.MockedFunction<AccountDB['accountEvent']['find']>
}
} as unknown as AccountDB
beforeEach(() => {
jest.clearAllMocks()
})
test('should return true when password changed after given timestamp', async () => {
const accountUuid = 'test-account-uuid' as AccountUuid
const now = Date.now()
const oneHourAgo = now - 1000 * 60 * 60 // 1 hour ago
const halfHourAgo = now - 1000 * 60 * 30 // 30 min ago
const mockEvent: AccountEvent = {
accountUuid,
eventType: AccountEventType.PASSWORD_CHANGED,
time: halfHourAgo
}
;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([mockEvent])
const result = await isPasswordChangedSince(mockDb, accountUuid, oneHourAgo)
expect(result).toBe(true)
})
test('should return false when password changed before given timestamp', async () => {
const accountUuid = 'test-account-uuid' as AccountUuid
const now = Date.now()
const oneMonthAgo = now - 1000 * 60 * 60 * 24 * 30 // 1 month ago
const twoMonthsAgo = now - 1000 * 60 * 60 * 24 * 60 * 2 // 2 months ago
const mockEvent: AccountEvent = {
accountUuid,
eventType: AccountEventType.PASSWORD_CHANGED,
time: twoMonthsAgo
}
;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([mockEvent])
const result = await isPasswordChangedSince(mockDb, accountUuid, oneMonthAgo)
expect(result).toBe(false)
})
test('should return false when no password change events exist', async () => {
const accountUuid = 'test-account-uuid' as AccountUuid
const now = Date.now()
;(mockDb.accountEvent.find as jest.Mock).mockResolvedValue([])
const result = await isPasswordChangedSince(mockDb, accountUuid, now)
expect(result).toBe(false)
})
})
})
describe('wrap', () => {
@@ -39,7 +39,8 @@ export function getMigrations (ns: string): [string, string][] {
getV18Migration(ns),
getV19Migration(ns),
getV20Migration(ns),
getV21Migration(ns)
getV21Migration(ns),
getV22Migration(ns)
]
}
@@ -579,3 +580,13 @@ function getV21Migration (ns: string): [string, string] {
`
]
}
function getV22Migration (ns: string): [string, string] {
return [
'account_db_v22_add_password_change_event_index',
`
CREATE INDEX IF NOT EXISTS account_events_account_uuid_event_type_time_idx
ON ${ns}.account_events (account_uuid, event_type, time DESC);
`
]
}
+2 -1
View File
@@ -78,7 +78,8 @@ export interface AccountEvent {
export enum AccountEventType {
ACCOUNT_CREATED = 'account_created',
SOCIAL_ID_RELEASED = 'social_id_released',
ACCOUNT_DELETED = 'account_deleted'
ACCOUNT_DELETED = 'account_deleted',
PASSWORD_CHANGED = 'password_changed'
}
export interface Member {
+31 -2
View File
@@ -48,6 +48,7 @@ import { accountPlugin } from './plugin'
import {
type Account,
type AccountDB,
type AccountEvent,
AccountEventType,
type AccountMethodHandler,
type Integration,
@@ -428,7 +429,7 @@ export async function setPassword (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
personUuid: AccountUuid,
accountUuid: AccountUuid,
password: string
): Promise<void> {
if (password == null || password === '') {
@@ -436,7 +437,35 @@ export async function setPassword (
}
const salt = randomBytes(32)
await db.setPassword(personUuid, hashWithSalt(password, salt), salt)
await db.setPassword(accountUuid, hashWithSalt(password, salt), salt)
// Record password change event
try {
await db.accountEvent.insertOne({
accountUuid,
eventType: AccountEventType.PASSWORD_CHANGED,
time: Date.now()
})
} catch (err) {
ctx.warn('Failed to record password change event', { accountUuid, err })
}
}
export async function getLastPasswordChangeEvent (
db: AccountDB,
accountUuid: AccountUuid
): Promise<AccountEvent | null> {
const result = await db.accountEvent.find(
{ accountUuid, eventType: AccountEventType.PASSWORD_CHANGED },
{ time: 'descending' },
1
)
return result[0] ?? null
}
export async function isPasswordChangedSince (db: AccountDB, accountUuid: AccountUuid, since: number): Promise<boolean> {
const lastEvent = await getLastPasswordChangeEvent(db, accountUuid)
return lastEvent != null && lastEvent.time >= since
}
export async function generateUniqueOtp (db: AccountDB): Promise<string> {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM hardcoreeng/front-base:v20250916
FROM hardcoreeng/base-slim:v20250916
WORKDIR /app
COPY bundle/bundle.js ./
+1 -1
View File
@@ -1,4 +1,4 @@
FROM hardcoreeng/base:v20250916
FROM hardcoreeng/base-slim:v20250916
WORKDIR /app
COPY bundle/bundle.js ./
@@ -14,7 +14,7 @@
//
export function getMigrations (): [string, string][] {
return [migrationV1(), migrationV2()]
return [migrationV1(), migrationV2(), migrationV3()]
}
function migrationV1 (): [string, string] {
@@ -77,3 +77,11 @@ function migrationV2 (): [string, string] {
`
return ['init_ai_usage_tables_02', sql]
}
function migrationV3 (): [string, string] {
const sql = `
UPDATE billing.livekit_session SET bandwidth = 0 WHERE bandwidth IS NULL;
ALTER TABLE billing.livekit_session ALTER COLUMN bandwidth SET DEFAULT 0;
`
return ['fix_bandwidth_nulls_03', sql]
}
@@ -204,7 +204,7 @@ class PostgresDB implements BillingDB {
values.push(
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`
)
params.push(workspace, sessionId, sessionStart, sessionEnd, room, bandwidth, minutes)
params.push(workspace, sessionId, sessionStart, sessionEnd, room, bandwidth ?? 0, minutes)
}
if (values.length === 0) continue
+1 -1
View File
@@ -1,5 +1,5 @@
FROM hardcoreeng/base:v20250916
FROM hardcoreeng/base-slim:v20250916
WORKDIR /usr/src/app
COPY bundle/bundle.js ./
+1 -1
View File
@@ -1,4 +1,4 @@
FROM hardcoreeng/base:v20250916
FROM hardcoreeng/base-slim:v20250916
WORKDIR /app
COPY bundle/bundle.js ./