feat: TTLock — add Учетная запись + Пароль fields for OAuth

Per Sciener docs: OAuth requires TTHotel account credentials (Учетная запись + Пароль)
separate from developer app client_id/client_secret.

- Migration 055: add ttlock_username, ttlock_password columns
- Backend: store and pass new fields to agent
- UI: add input fields matching TTHotel PMS integration page
- Agent: getAccessToken() uses ttlockUsername/ttlockPassword when provided

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 11:46:30 +03:00
parent a7b59c4881
commit 8c215f2dcd
4 changed files with 108 additions and 58 deletions

View File

@@ -0,0 +1,5 @@
-- TTHotel account credentials for OAuth (separate from developer app client_id/secret)
-- "Учетная запись" + "Пароль" from TTHotel → Settings → PMS Integration
ALTER TABLE hotel_ttlock_config
ADD COLUMN IF NOT EXISTS ttlock_username TEXT, -- "Учетная запись", e.g. h_1754382851163
ADD COLUMN IF NOT EXISTS ttlock_password TEXT; -- "Пароль" (stored as-is, md5'd before OAuth call)

View File

@@ -47,11 +47,11 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query( const { rows } = await db.query(
`SELECT is_enabled, client_id, card_sectors, api_server FROM hotel_ttlock_config WHERE hotel_id = $1`, `SELECT is_enabled, client_id, card_sectors, api_server, ttlock_username FROM hotel_ttlock_config WHERE hotel_id = $1`,
[hotelId], [hotelId],
) )
if (!rows[0]) { if (!rows[0]) {
return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10', apiServer: 'https://euapi.ttlock.com' } return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10', apiServer: 'https://euapi.ttlock.com', ttlockUsername: '' }
} }
const r = rows[0] const r = rows[0]
return { return {
@@ -59,6 +59,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
clientId: r.client_id, clientId: r.client_id,
cardSectors: r.card_sectors, cardSectors: r.card_sectors,
apiServer: r.api_server ?? 'https://euapi.ttlock.com', apiServer: r.api_server ?? 'https://euapi.ttlock.com',
ttlockUsername: r.ttlock_username ?? '',
} }
}) })
@@ -70,6 +71,8 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
client_secret?: string client_secret?: string
card_sectors?: string card_sectors?: string
api_server?: string api_server?: string
ttlock_username?: string
ttlock_password?: string
} }
}>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => { }>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug } = req.params const { slug } = req.params
@@ -78,17 +81,23 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug) const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { is_enabled: isEnabled, client_id: clientId, client_secret: clientSecret, card_sectors: cardSectors, api_server: apiServer } = req.body const {
is_enabled: isEnabled, client_id: clientId, client_secret: clientSecret,
card_sectors: cardSectors, api_server: apiServer,
ttlock_username: ttlockUsername, ttlock_password: ttlockPassword,
} = req.body
await db.query( await db.query(
`INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors, api_server) `INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors, api_server, ttlock_username, ttlock_password)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (hotel_id) DO UPDATE SET ON CONFLICT (hotel_id) DO UPDATE SET
is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled), is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled),
client_id = COALESCE($3, hotel_ttlock_config.client_id), client_id = COALESCE($3, hotel_ttlock_config.client_id),
client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret), client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret),
card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors), card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors),
api_server = COALESCE($6, hotel_ttlock_config.api_server), api_server = COALESCE($6, hotel_ttlock_config.api_server),
ttlock_username = COALESCE(NULLIF($7, ''), hotel_ttlock_config.ttlock_username),
ttlock_password = COALESCE(NULLIF($8, ''), hotel_ttlock_config.ttlock_password),
updated_at = NOW()`, updated_at = NOW()`,
[ [
hotelId, hotelId,
@@ -97,6 +106,8 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
clientSecret ?? '', clientSecret ?? '',
cardSectors ?? null, cardSectors ?? null,
apiServer ?? null, apiServer ?? null,
ttlockUsername ?? '',
ttlockPassword ?? '',
], ],
) )
return { ok: true } return { ok: true }
@@ -173,7 +184,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows: cfgRows } = await db.query( const { rows: cfgRows } = await db.query(
'SELECT client_id, client_secret, api_server FROM hotel_ttlock_config WHERE hotel_id = $1', 'SELECT client_id, client_secret, api_server, ttlock_username, ttlock_password FROM hotel_ttlock_config WHERE hotel_id = $1',
[hotelId], [hotelId],
) )
const cfg = cfgRows[0] const cfg = cfgRows[0]
@@ -187,6 +198,8 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
type: 'ttlock:test_api', type: 'ttlock:test_api',
clientId: cfg.client_id, clientId: cfg.client_id,
clientSecret: cfg.client_secret, clientSecret: cfg.client_secret,
ttlockUsername: cfg.ttlock_username ?? '',
ttlockPassword: cfg.ttlock_password ?? '',
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com', apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
}, 15_000) as { ok?: boolean; lockCount?: number; error?: string } }, 15_000) as { ok?: boolean; lockCount?: number; error?: string }
@@ -340,7 +353,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
// Конфигурация TTHotel (client_id, card_sectors) // Конфигурация TTHotel (client_id, card_sectors)
const { rows: cfgRows } = await db.query( const { rows: cfgRows } = await db.query(
'SELECT client_id, client_secret, card_sectors, is_enabled, api_server FROM hotel_ttlock_config WHERE hotel_id = $1', 'SELECT client_id, client_secret, card_sectors, is_enabled, api_server, ttlock_username, ttlock_password FROM hotel_ttlock_config WHERE hotel_id = $1',
[hotelId], [hotelId],
) )
const cfg = cfgRows[0] const cfg = cfgRows[0]
@@ -394,6 +407,8 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
cardSectors: cfg.card_sectors, cardSectors: cfg.card_sectors,
clientId: cfg.client_id, clientId: cfg.client_id,
clientSecret: cfg.client_secret, clientSecret: cfg.client_secret,
ttlockUsername: cfg.ttlock_username ?? '',
ttlockPassword: cfg.ttlock_password ?? '',
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com', apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
startDate: checkIn, startDate: checkIn,
endDate: checkOut, endDate: checkOut,

View File

@@ -1098,12 +1098,15 @@ export interface TTLockConfig {
clientId: string clientId: string
cardSectors: string cardSectors: string
apiServer: string apiServer: string
ttlockUsername: string
} }
export interface TTLockConfigUpdate { export interface TTLockConfigUpdate {
isEnabled?: boolean isEnabled?: boolean
clientId?: string clientId?: string
clientSecret?: string clientSecret?: string
ttlock_username?: string
ttlock_password?: string
cardSectors?: string cardSectors?: string
apiServer?: string apiServer?: string
} }

View File

@@ -142,6 +142,8 @@ export function TTLockPage() {
const [isEnabled, setIsEnabled] = useState(false) const [isEnabled, setIsEnabled] = useState(false)
const [clientId, setClientId] = useState('') const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('') const [clientSecret, setClientSecret] = useState('')
const [ttlockUsername, setTtlockUsername] = useState('')
const [ttlockPassword, setTtlockPassword] = useState('')
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10') const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
const [apiServer, setApiServer] = useState('https://euapi.ttlock.com') const [apiServer, setApiServer] = useState('https://euapi.ttlock.com')
@@ -242,6 +244,7 @@ export function TTLockPage() {
setConfig(cfg) setConfig(cfg)
setIsEnabled(cfg.isEnabled) setIsEnabled(cfg.isEnabled)
setClientId(cfg.clientId) setClientId(cfg.clientId)
setTtlockUsername(cfg.ttlockUsername || '')
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10') setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
setApiServer(cfg.apiServer || 'https://euapi.ttlock.com') setApiServer(cfg.apiServer || 'https://euapi.ttlock.com')
setMappings(maps) setMappings(maps)
@@ -265,11 +268,14 @@ export function TTLockPage() {
isEnabled, isEnabled,
clientId: clientId.trim(), clientId: clientId.trim(),
clientSecret: clientSecret.trim() || undefined, clientSecret: clientSecret.trim() || undefined,
ttlock_username: ttlockUsername.trim() || undefined,
ttlock_password: ttlockPassword.trim() || undefined,
cardSectors: cardSectors.trim(), cardSectors: cardSectors.trim(),
apiServer: apiServer, apiServer: apiServer,
}) })
showSuccess('Настройки сохранены') showSuccess('Настройки сохранены')
setClientSecret('') setClientSecret('')
setTtlockPassword('')
await load() await load()
} catch { } catch {
setError('Ошибка сохранения') setError('Ошибка сохранения')
@@ -410,6 +416,27 @@ export function TTLockPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="form-label">Учетная запись <span className="font-normal text-slate-400">(поле «Учетная запись» в TTHotel)</span></label>
<input
type="text"
value={ttlockUsername}
onChange={e => setTtlockUsername(e.target.value)}
placeholder="h_1754382851163"
className="input w-full font-mono text-sm"
/>
</div>
<div>
<label className="form-label">Пароль <span className="font-normal text-slate-400">(поле «Пароль» в TTHotel)</span></label>
<PasswordInput
value={ttlockPassword}
onChange={setTtlockPassword}
placeholder={ttlockUsername && !ttlockPassword ? '••••••• (не изменится)' : 'Пароль от учетной записи TTHotel'}
/>
</div>
</div>
{/* Сервер TTLock API */} {/* Сервер TTLock API */}
<div> <div>
<label className="form-label">Сервер TTLock API</label> <label className="form-label">Сервер TTLock API</label>