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:
5
backend/migrations/055_ttlock_credentials.sql
Normal file
5
backend/migrations/055_ttlock_credentials.sql
Normal 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)
|
||||||
@@ -47,29 +47,32 @@ 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 {
|
||||||
isEnabled: r.is_enabled,
|
isEnabled: r.is_enabled,
|
||||||
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 ?? '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── PATCH /api/hotels/:slug/ttlock/config ───────────────────────────────────
|
// ── PATCH /api/hotels/:slug/ttlock/config ───────────────────────────────────
|
||||||
fastify.patch<SlugParam & {
|
fastify.patch<SlugParam & {
|
||||||
Body: {
|
Body: {
|
||||||
is_enabled?: boolean
|
is_enabled?: boolean
|
||||||
client_id?: string
|
client_id?: string
|
||||||
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,25 +81,33 @@ 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),
|
||||||
updated_at = NOW()`,
|
ttlock_username = COALESCE(NULLIF($7, ''), hotel_ttlock_config.ttlock_username),
|
||||||
|
ttlock_password = COALESCE(NULLIF($8, ''), hotel_ttlock_config.ttlock_password),
|
||||||
|
updated_at = NOW()`,
|
||||||
[
|
[
|
||||||
hotelId,
|
hotelId,
|
||||||
isEnabled ?? null,
|
isEnabled ?? null,
|
||||||
clientId ?? null,
|
clientId ?? null,
|
||||||
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]
|
||||||
@@ -184,10 +195,12 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sendCommandAndWait(workstationId, {
|
const result = await sendCommandAndWait(workstationId, {
|
||||||
type: 'ttlock:test_api',
|
type: 'ttlock:test_api',
|
||||||
clientId: cfg.client_id,
|
clientId: cfg.client_id,
|
||||||
clientSecret: cfg.client_secret,
|
clientSecret: cfg.client_secret,
|
||||||
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
|
ttlockUsername: cfg.ttlock_username ?? '',
|
||||||
|
ttlockPassword: cfg.ttlock_password ?? '',
|
||||||
|
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 }
|
||||||
|
|
||||||
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка API' })
|
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка API' })
|
||||||
@@ -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]
|
||||||
@@ -388,15 +401,17 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
let result: unknown
|
let result: unknown
|
||||||
try {
|
try {
|
||||||
result = await sendCommandAndWait(workstationId, {
|
result = await sendCommandAndWait(workstationId, {
|
||||||
type: 'ttlock:write_card',
|
type: 'ttlock:write_card',
|
||||||
lockMac: lock.lock_mac,
|
lockMac: lock.lock_mac,
|
||||||
comPort: comPort,
|
comPort: comPort,
|
||||||
cardSectors: cfg.card_sectors,
|
cardSectors: cfg.card_sectors,
|
||||||
clientId: cfg.client_id,
|
clientId: cfg.client_id,
|
||||||
clientSecret: cfg.client_secret,
|
clientSecret: cfg.client_secret,
|
||||||
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
|
ttlockUsername: cfg.ttlock_username ?? '',
|
||||||
startDate: checkIn,
|
ttlockPassword: cfg.ttlock_password ?? '',
|
||||||
endDate: checkOut,
|
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
|
||||||
|
startDate: checkIn,
|
||||||
|
endDate: checkOut,
|
||||||
}, 30_000)
|
}, 30_000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
||||||
|
|||||||
@@ -1094,18 +1094,21 @@ export interface Workstation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TTLockConfig {
|
export interface TTLockConfig {
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
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
|
||||||
cardSectors?: string
|
ttlock_username?: string
|
||||||
apiServer?: string
|
ttlock_password?: string
|
||||||
|
cardSectors?: string
|
||||||
|
apiServer?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomLockMapping {
|
export interface RoomLockMapping {
|
||||||
|
|||||||
@@ -139,11 +139,13 @@ export function TTLockPage() {
|
|||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
// Форма настроек
|
// Форма настроек
|
||||||
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 [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
|
const [ttlockUsername, setTtlockUsername] = useState('')
|
||||||
const [apiServer, setApiServer] = useState('https://euapi.ttlock.com')
|
const [ttlockPassword, setTtlockPassword] = useState('')
|
||||||
|
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
|
||||||
|
const [apiServer, setApiServer] = useState('https://euapi.ttlock.com')
|
||||||
|
|
||||||
// Per-workstation COM port state
|
// Per-workstation COM port state
|
||||||
const [wsComPorts, setWsComPorts] = useState<Record<string, string>>({})
|
const [wsComPorts, setWsComPorts] = useState<Record<string, string>>({})
|
||||||
@@ -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)
|
||||||
@@ -263,13 +266,16 @@ export function TTLockPage() {
|
|||||||
try {
|
try {
|
||||||
await api.ttlock.updateConfig(slug, {
|
await api.ttlock.updateConfig(slug, {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
clientId: clientId.trim(),
|
clientId: clientId.trim(),
|
||||||
clientSecret: clientSecret.trim() || undefined,
|
clientSecret: clientSecret.trim() || undefined,
|
||||||
cardSectors: cardSectors.trim(),
|
ttlock_username: ttlockUsername.trim() || undefined,
|
||||||
apiServer: apiServer,
|
ttlock_password: ttlockPassword.trim() || undefined,
|
||||||
|
cardSectors: cardSectors.trim(),
|
||||||
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user