Add admin official account connection UI and persistent session secrets
This commit is contained in:
@@ -213,3 +213,20 @@ compatible; new endpoints are included in generated OpenAPI and contract tests.
|
|||||||
The daily-poetry template includes 20 public-domain classical Chinese poems, selected deterministically by Shanghai date, or manually. Create a daily job from its editor; new poetry jobs are paused by default until you enable them. Custom title, author and verses are editable. This is an independent Frame Flow content provider, not the KoKonna mini-program subscription. Official public API does not expose mini-program subscription/template settings.
|
The daily-poetry template includes 20 public-domain classical Chinese poems, selected deterministically by Shanghai date, or manually. Create a daily job from its editor; new poetry jobs are paused by default until you enable them. Custom title, author and verses are editable. This is an independent Frame Flow content provider, not the KoKonna mini-program subscription. Official public API does not expose mini-program subscription/template settings.
|
||||||
|
|
||||||
每日诗词支持按上海日期选诗、手选 20 首古典诗词、自定义文本以及每日任务。模板显示操作使用统一上传/显示服务;真实写入须开启 KOKONNA_WRITE_ENABLED,结果未知不会自动重试。官方小程序原有每日诗词订阅不能通过当前公开 API 查询或修改。
|
每日诗词支持按上海日期选诗、手选 20 首古典诗词、自定义文本以及每日任务。模板显示操作使用统一上传/显示服务;真实写入须开启 KOKONNA_WRITE_ENABLED,结果未知不会自动重试。官方小程序原有每日诗词订阅不能通过当前公开 API 查询或修改。
|
||||||
|
|
||||||
|
|
||||||
|
### 官方账号连接 / Official account connection
|
||||||
|
|
||||||
|
在 Templates → 官方小组件中,由管理员输入 KoKonna 官方账号和密码,查询账号下
|
||||||
|
的设备后手动关联当前 Frame Flow 设备。此流程不绑定或解绑云端设备,也不改变
|
||||||
|
图片或订阅。现有 API Key 接入继续负责设备、图库和显示能力。
|
||||||
|
|
||||||
|
密码不保存;会话与关联信息保存在 `/config/official-{device_id}.secret`,权限 0600。
|
||||||
|
容器重启可恢复连接。会话过期需要重新登录;断开连接会清除本地会话,不撤销
|
||||||
|
官方服务器上的其他登录。局域网访问沿用现有部署方式。
|
||||||
|
|
||||||
|
Official account connection is admin-only and separate from API Key access.
|
||||||
|
Login, device discovery, local association and disconnect are available through
|
||||||
|
`/api/v1/devices/{device_id}/official/connection`. Passwords are never persisted;
|
||||||
|
server-side sessions are stored as restricted secret files. No official widget
|
||||||
|
mutation or other extra RPC write is enabled by connecting.
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ reset, firmware, payment or subscription mutation was executed during research.
|
|||||||
|
|
||||||
- API Key device/gallery/upload/display support remains unchanged.
|
- API Key device/gallery/upload/display support remains unchanged.
|
||||||
- Official widget panel separately explains account authorization requirements.
|
- Official widget panel separately explains account authorization requirements.
|
||||||
- The existing WidgetReader uses a server-side official session secret; direct
|
- The existing WidgetReader uses a server-side official session secret; admin
|
||||||
account/password connection UI and automatic session renewal are not implemented.
|
account/password connection UI is implemented; automatic session renewal is not.
|
||||||
- Live `NO_WIDGET` requires explicit null current-widget association and null
|
- Live `NO_WIDGET` requires explicit null current-widget association and null
|
||||||
robot association ID; missing fields remain `CURRENT_UNVERIFIED`.
|
robot association ID; missing fields remain `CURRENT_UNVERIFIED`.
|
||||||
- Unconfigured access returns `AUTH_REQUIRED` and an explicitly dated snapshot.
|
- Unconfigured access returns `AUTH_REQUIRED` and an explicitly dated snapshot.
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ from pigway_frame_flow.core.registry import DeviceRegistry
|
|||||||
from pigway_frame_flow.display.service import DisplayService
|
from pigway_frame_flow.display.service import DisplayService
|
||||||
from pigway_frame_flow.errors import ErrorCode as E
|
from pigway_frame_flow.errors import ErrorCode as E
|
||||||
from pigway_frame_flow.errors import FlowError
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
from pigway_frame_flow.integrations.kokonna_official.connection import (
|
||||||
|
ConnectionState,
|
||||||
|
LoginRequest,
|
||||||
|
SelectRequest,
|
||||||
|
)
|
||||||
from pigway_frame_flow.models import (
|
from pigway_frame_flow.models import (
|
||||||
Accepted,
|
Accepted,
|
||||||
Asset,
|
Asset,
|
||||||
@@ -753,6 +758,48 @@ def create_app(settings=None, start_worker=True):
|
|||||||
weather_provider=weather.config().provider,
|
weather_provider=weather.config().provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def official_connection(device_id):
|
||||||
|
registry.get(device_id)
|
||||||
|
return registry.official[device_id]
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/api/v1/devices/{device_id}/official/connection",
|
||||||
|
response_model=ConnectionState,
|
||||||
|
tags=["Official"],
|
||||||
|
dependencies=[Depends(require("admin"))],
|
||||||
|
)
|
||||||
|
def connection_state(device_id: str, discover: bool = False):
|
||||||
|
return official_connection(device_id).state(discover)
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/devices/{device_id}/official/connection",
|
||||||
|
response_model=ConnectionState,
|
||||||
|
tags=["Official"],
|
||||||
|
dependencies=[Depends(require("admin"))],
|
||||||
|
)
|
||||||
|
def connection_login(device_id: str, body: LoginRequest):
|
||||||
|
return official_connection(device_id).login(
|
||||||
|
body.username, body.password.get_secret_value()
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.put(
|
||||||
|
"/api/v1/devices/{device_id}/official/connection",
|
||||||
|
response_model=ConnectionState,
|
||||||
|
tags=["Official"],
|
||||||
|
dependencies=[Depends(require("admin"))],
|
||||||
|
)
|
||||||
|
def connection_select(device_id: str, body: SelectRequest):
|
||||||
|
return official_connection(device_id).select(body.robot_id)
|
||||||
|
|
||||||
|
@app.delete(
|
||||||
|
"/api/v1/devices/{device_id}/official/connection",
|
||||||
|
response_model=ConnectionState,
|
||||||
|
tags=["Official"],
|
||||||
|
dependencies=[Depends(require("admin"))],
|
||||||
|
)
|
||||||
|
def connection_disconnect(device_id: str):
|
||||||
|
return official_connection(device_id).disconnect()
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/api/v1/devices/{device_id}/widgets",
|
"/api/v1/devices/{device_id}/widgets",
|
||||||
response_model=__import__(
|
response_model=__import__(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pigway_frame_flow.drivers.kokonna.driver import KokonnaDriver
|
|||||||
from pigway_frame_flow.drivers.kokonna.mock import MockClient
|
from pigway_frame_flow.drivers.kokonna.mock import MockClient
|
||||||
from pigway_frame_flow.errors import ErrorCode as E
|
from pigway_frame_flow.errors import ErrorCode as E
|
||||||
from pigway_frame_flow.errors import FlowError
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
from pigway_frame_flow.integrations.kokonna_official.connection import Connection
|
||||||
from pigway_frame_flow.integrations.kokonna_official.widgets import WidgetReader
|
from pigway_frame_flow.integrations.kokonna_official.widgets import WidgetReader
|
||||||
from pigway_frame_flow.settings import secret
|
from pigway_frame_flow.settings import secret
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ class DeviceRegistry:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.drivers: dict[str, FrameDeviceDriver] = {}
|
self.drivers: dict[str, FrameDeviceDriver] = {}
|
||||||
self.official: dict[str, WidgetReader] = {}
|
self.official: dict[str, Connection] = {}
|
||||||
self.keys = []
|
self.keys = []
|
||||||
for id, entry in devices.items():
|
for id, entry in devices.items():
|
||||||
if not entry.get("enabled", True):
|
if not entry.get("enabled", True):
|
||||||
@@ -56,7 +57,11 @@ class DeviceRegistry:
|
|||||||
widgets = WidgetReader(
|
widgets = WidgetReader(
|
||||||
widget_token, entry.get("widget_robot_id"), settings.mock
|
widget_token, entry.get("widget_robot_id"), settings.mock
|
||||||
)
|
)
|
||||||
self.official[id] = widgets
|
self.official[id] = Connection(
|
||||||
|
widgets,
|
||||||
|
settings.config_dir / f"official-{id}.secret",
|
||||||
|
settings.additional_secrets,
|
||||||
|
)
|
||||||
self.drivers[id] = KokonnaDriver(client, id, settings.sanitize)
|
self.drivers[id] = KokonnaDriver(client, id, settings.sanitize)
|
||||||
|
|
||||||
def get(self, id: str) -> FrameDeviceDriver:
|
def get(self, id: str) -> FrameDeviceDriver:
|
||||||
@@ -66,4 +71,4 @@ class DeviceRegistry:
|
|||||||
|
|
||||||
def official_widgets(self, id: str):
|
def official_widgets(self, id: str):
|
||||||
self.get(id)
|
self.get(id)
|
||||||
return self.official[id].read()
|
return self.official[id].widgets()
|
||||||
|
|||||||
@@ -13,11 +13,19 @@ FrameDeviceDriver protocol does not depend on account login or widgets.
|
|||||||
|
|
||||||
The public `/api/v1/devices/{device_id}/widgets` endpoint is unchanged. The old
|
The public `/api/v1/devices/{device_id}/widgets` endpoint is unchanged. The old
|
||||||
`drivers.kokonna.widgets.WidgetReader` import remains a compatibility re-export.
|
`drivers.kokonna.widgets.WidgetReader` import remains a compatibility re-export.
|
||||||
No raw RPC proxy or public password-login endpoint is introduced. Future admin
|
No raw RPC proxy is exposed. The admin-only `/official/connection` endpoint
|
||||||
account connection UI must use this module and keep credentials server-side.
|
provides login (POST), state/discovery (GET), local frame association (PUT), and
|
||||||
No account or session secret is automatically persisted by this module.
|
local disconnect (DELETE), under `/api/v1/devices/{device_id}`. Browser requests
|
||||||
|
use the existing Frame Flow session and CSRF protection; API clients require admin.
|
||||||
|
|
||||||
|
`connection.py` persists the official session and selected robot in
|
||||||
|
`/config/official-{device_id}.secret`, atomically with mode 0600. Passwords are
|
||||||
|
transient and never stored. These files must not be committed or exposed to users.
|
||||||
|
Disconnect clears the local secret, including on restart; it does not revoke
|
||||||
|
remote official account sessions. Expired sessions require logging in again.
|
||||||
|
No login refresh or stored-password automatic retry is performed.
|
||||||
|
|
||||||
设备 API Key 仍负责现有设备、图片和图库接口。官方账号功能集中在本目录,
|
设备 API Key 仍负责现有设备、图片和图库接口。官方账号功能集中在本目录,
|
||||||
不进入 DisplayService、Renderer、Scheduler 或 FrameDeviceDriver。
|
不进入 DisplayService、Renderer、Scheduler 或 FrameDeviceDriver。
|
||||||
账号登录已提供内部客户端能力;页面连接入口、写操作和会话持久化尚未实现。
|
账号连接页面与会话持久化已实现;额外写操作尚未实现。
|
||||||
所有已发现但未启用的 RPC 方法均拒绝执行,不复用 API Key 的写入开关。
|
所有已发现但未启用的 RPC 方法均拒绝执行,不复用 API Key 的写入开关。
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Admin account connection; password transient, session persisted as a secret."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field, SecretStr
|
||||||
|
|
||||||
|
from pigway_frame_flow.errors import ErrorCode as E
|
||||||
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
from pigway_frame_flow.integrations.kokonna_official.rpc import OfficialRPC
|
||||||
|
from pigway_frame_flow.models import StrictModel
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(StrictModel):
|
||||||
|
username: str = Field(min_length=1, max_length=120)
|
||||||
|
password: SecretStr = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectRequest(StrictModel):
|
||||||
|
robot_id: int = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class OfficialDevice(StrictModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
model: str | None = None
|
||||||
|
model_code: str | None = None
|
||||||
|
online: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionState(StrictModel):
|
||||||
|
authorized: bool
|
||||||
|
username: str
|
||||||
|
robot_id: int | None
|
||||||
|
mock: bool
|
||||||
|
devices: list[OfficialDevice] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def __init__(self, reader, path: Path, secrets: list):
|
||||||
|
self.reader, self.path, self.secrets = reader, path, secrets
|
||||||
|
self.username = ""
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
if path.exists():
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
reader.rpc = OfficialRPC(data["token"])
|
||||||
|
reader._robot_id = data["robot_id"]
|
||||||
|
self.username = data["username"]
|
||||||
|
self.secrets.append(data["token"])
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", dir=self.path.parent, delete=False
|
||||||
|
) as f:
|
||||||
|
temp = Path(f.name)
|
||||||
|
try:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"token": self.reader.rpc._token,
|
||||||
|
"username": self.username,
|
||||||
|
"robot_id": self.reader._robot_id,
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
temp.chmod(0o600)
|
||||||
|
os.replace(temp, self.path)
|
||||||
|
finally:
|
||||||
|
temp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _devices(self, rpc):
|
||||||
|
if self.reader.mock:
|
||||||
|
return [
|
||||||
|
OfficialDevice(
|
||||||
|
id=1,
|
||||||
|
name="Mock Frame",
|
||||||
|
model="13.3-inch",
|
||||||
|
model_code="B101",
|
||||||
|
online=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
data = rpc.devices()["data"]
|
||||||
|
return [
|
||||||
|
OfficialDevice(
|
||||||
|
id=row["id"],
|
||||||
|
name=row.get("robotNickname") or "Frame",
|
||||||
|
model=(row.get("model") or {}).get("name"),
|
||||||
|
model_code=(row.get("model") or {}).get("code"),
|
||||||
|
online=bool(row.get("online")),
|
||||||
|
)
|
||||||
|
for row in data
|
||||||
|
]
|
||||||
|
except (KeyError, TypeError, ValueError, AttributeError):
|
||||||
|
raise FlowError(
|
||||||
|
E.CLOUD_UNAVAILABLE, "Official device list unavailable", 502
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def state(self, discovery=False):
|
||||||
|
with self.lock:
|
||||||
|
return ConnectionState(
|
||||||
|
authorized=self.reader.rpc.authorized,
|
||||||
|
username=self.username,
|
||||||
|
robot_id=self.reader._robot_id,
|
||||||
|
mock=self.reader.mock,
|
||||||
|
devices=self._devices(self.reader.rpc)
|
||||||
|
if discovery and self.reader.rpc.authorized
|
||||||
|
else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
def login(self, username, password):
|
||||||
|
with self.lock:
|
||||||
|
rpc = OfficialRPC("mock-session" if self.reader.mock else "")
|
||||||
|
if not self.reader.mock:
|
||||||
|
rpc.login(username, password)
|
||||||
|
devices = self._devices(rpc)
|
||||||
|
self.reader.rpc = rpc
|
||||||
|
self.reader._robot_id = None
|
||||||
|
self.reader._cached = None
|
||||||
|
self.username = username
|
||||||
|
self.secrets.append(rpc._token)
|
||||||
|
self._save()
|
||||||
|
return ConnectionState(
|
||||||
|
authorized=True,
|
||||||
|
username=username,
|
||||||
|
robot_id=None,
|
||||||
|
mock=self.reader.mock,
|
||||||
|
devices=devices,
|
||||||
|
)
|
||||||
|
|
||||||
|
def select(self, robot_id):
|
||||||
|
with self.lock:
|
||||||
|
if not self.reader.rpc.authorized:
|
||||||
|
raise FlowError(
|
||||||
|
E.CONFIG_REQUIRED, "Official account authorization required", 503
|
||||||
|
)
|
||||||
|
if not any(d.id == robot_id for d in self._devices(self.reader.rpc)):
|
||||||
|
raise FlowError(
|
||||||
|
E.DEVICE_NOT_FOUND, "Official device not in this account", 404
|
||||||
|
)
|
||||||
|
self.reader._robot_id = robot_id
|
||||||
|
self.reader._cached = None
|
||||||
|
self._save()
|
||||||
|
return self.state()
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
with self.lock:
|
||||||
|
self.reader.rpc.disconnect()
|
||||||
|
self.reader._robot_id = None
|
||||||
|
self.reader._cached = None
|
||||||
|
self.username = ""
|
||||||
|
self._save()
|
||||||
|
return self.state()
|
||||||
|
|
||||||
|
def widgets(self):
|
||||||
|
with self.lock:
|
||||||
|
return self.reader.read()
|
||||||
@@ -46,7 +46,7 @@ class WidgetReader:
|
|||||||
configured=False,
|
configured=False,
|
||||||
catalog=catalog,
|
catalog=catalog,
|
||||||
observed_at=snapshot["observed_at"],
|
observed_at=snapshot["observed_at"],
|
||||||
status="AUTH_REQUIRED",
|
status="DEVICE_REQUIRED" if self.rpc.authorized else "AUTH_REQUIRED",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
data = self.rpc.widgets(self._robot_id)
|
data = self.rpc.widgets(self._robot_id)
|
||||||
|
|||||||
@@ -81,9 +81,24 @@ async function officialContentInfo(){
|
|||||||
try {
|
try {
|
||||||
const data=await api('/api/v1/devices/'+state.device+'/widgets');
|
const data=await api('/api/v1/devices/'+state.device+'/widgets');
|
||||||
const label=data.source==='live'?(zh?'实时目录':'Live catalog'):data.source==='mock'?'Mock':(zh?'历史目录 · '+data.observed_at:'Catalog snapshot · '+data.observed_at);
|
const label=data.source==='live'?(zh?'实时目录':'Live catalog'):data.source==='mock'?'Mock':(zh?'历史目录 · '+data.observed_at:'Catalog snapshot · '+data.observed_at);
|
||||||
const currentLabel=!data.configured?(zh?'尚未授权官方账号,当前状态不可读取。':'Official account not authorized; current state is unavailable.'):data.status==='NO_WIDGET'?(zh?'未使用官方小组件':'No official widget selected'):data.current_verified&&data.current?((zh?'当前小组件:':'Current widget: ')+data.current.name):(zh?'当前小组件尚未核实':'Current widget is unverified');
|
const currentLabel=data.status==='DEVICE_REQUIRED'?(zh?'官方账号已连接,请先关联画框':'Official account connected; link a frame first'):!data.configured?(zh?'尚未授权官方账号,当前状态不可读取。':'Official account not authorized; current state is unavailable.'):data.status==='NO_WIDGET'?(zh?'未使用官方小组件':'No official widget selected'):data.current_verified&&data.current?((zh?'当前小组件:':'Current widget: ')+data.current.name):(zh?'当前小组件尚未核实':'Current widget is unverified');
|
||||||
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}</summary><p>${esc(label)}</p><p class="notice">${esc(currentLabel)}</p><p class="notice widget-auth-notice">${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}</p><p>${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}</p><input type="search" aria-label="${zh?'搜索小组件':'Search widgets'}" placeholder="${zh?'搜索名称或分类':'Search name or category'}"><div class="widget-list"></div>`;
|
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}</summary><p>${esc(label)}</p><p class="notice">${esc(currentLabel)}</p><p class="notice widget-auth-notice">${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}</p><p>${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}</p><input type="search" aria-label="${zh?'搜索小组件':'Search widgets'}" placeholder="${zh?'搜索名称或分类':'Search name or category'}"><div class="widget-list"></div>`;
|
||||||
const render=()=>{const query=info.querySelector('input').value.toLowerCase();info.querySelector('.widget-list').innerHTML=data.catalog.filter(x=>(x.name+' '+x.tags.join(' ')).toLowerCase().includes(query)).map(x=>`<article class="card"><strong>${esc(x.name)}</strong><p>${esc(x.tags.join(' · '))}</p><small>${esc(x.arg_keys.join(' · '))}</small></article>`).join('');};
|
const render=()=>{const query=info.querySelector('input').value.toLowerCase();info.querySelector('.widget-list').innerHTML=data.catalog.filter(x=>(x.name+' '+x.tags.join(' ')).toLowerCase().includes(query)).map(x=>`<article class="card"><strong>${esc(x.name)}</strong><p>${esc(x.tags.join(' · '))}</p><small>${esc(x.arg_keys.join(' · '))}</small></article>`).join('');};
|
||||||
info.querySelector('input').oninput=render;render();
|
info.querySelector('input').oninput=render;render();officialConnectionPanel(info);
|
||||||
}catch(e){info.querySelector('p').textContent=e.message;}
|
}catch(e){info.querySelector('p').textContent=e.message;}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function officialConnectionPanel(info){
|
||||||
|
const zh=state.lang==='zh',path='/api/v1/devices/'+state.device+'/official/connection';
|
||||||
|
const panel=document.createElement('section');panel.className='official-account-panel';info.querySelector('.widget-auth-notice').after(panel);
|
||||||
|
const render=connection=>{
|
||||||
|
panel.innerHTML=connection.authorized?`<h3>${zh?'官方账号已连接':'Official account connected'}</h3><p>${esc(connection.username)}</p><p>${connection.robot_id?(zh?'已关联画框':'Frame linked'):(zh?'请选择并关联你的画框':'Choose and link your frame')}</p><div class="actions"><button id="official-discover" type="button">${zh?'查询账号设备':'Discover devices'}</button><button id="official-disconnect" type="button">${zh?'断开连接':'Disconnect'}</button></div><div id="official-device-picker"></div>`:`<h3>${zh?'连接 KoKonna 官方账号':'Connect KoKonna official account'}</h3><p>${zh?'此处不是 Frame Flow 或 NAS 账号。密码不保存,登录会话会保存为 NAS 上受限的 secret 文件。连接仅查询,不切换小组件。':'Use your KoKonna account, not Frame Flow or NAS credentials. Passwords are not saved; the session is stored in a restricted NAS secret file. Connecting only queries data.'}</p><form id="official-login-form"><div class="field"><label for="official-username">${zh?'官方账号':'Official username'}</label><input id="official-username" required maxlength="120" autocomplete="username"></div><div class="field"><label for="official-password">${zh?'官方密码':'Official password'}</label><input id="official-password" required maxlength="256" type="password" autocomplete="off"></div><button type="submit" class="primary">${zh?'授权连接':'Authorize connection'}</button></form>`;
|
||||||
|
if(connection.authorized){
|
||||||
|
$('#official-disconnect').onclick=busy(async()=>{render(await api(path,{method:'DELETE'}));toast(zh?'已断开官方连接':'Official connection disconnected');});
|
||||||
|
const picker=devices=>{const box=$('#official-device-picker');box.innerHTML=devices.length?`<form id="official-select-form"><div class="field"><label for="official-device">${zh?'官方账号下的画框':'Frames in your official account'}</label><select id="official-device">${devices.map(d=>`<option value="${d.id}" ${d.id===connection.robot_id?'selected':''}>${esc(d.name)} · ${esc(d.model_code||'')} · ${d.online?'ONLINE':'OFFLINE'}</option>`).join('')}</select></div><button type="submit" class="primary">${zh?'关联到当前 Frame Flow 设备':'Link to this Frame Flow device'}</button></form>`:`<p>${zh?'账号下没有可关联设备':'No devices found in this account'}</p>`;if(devices.length)$('#official-select-form').onsubmit=busy(async e=>{e.preventDefault();await api(path,{method:'PUT',body:{robot_id:Number($('#official-device').value)}});await navigate('templates');});};
|
||||||
|
$('#official-discover').onclick=busy(async()=>{picker((await api(path+'?discover=true')).devices);});
|
||||||
|
if(connection.devices.length)picker(connection.devices);
|
||||||
|
}else $('#official-login-form').onsubmit=busy(async e=>{e.preventDefault();const username=$('#official-username').value.trim(),password=$('#official-password').value;$('#official-password').value='';render(await api(path,{method:'POST',body:{username,password}}));});
|
||||||
|
};
|
||||||
|
try{render(await api(path));}catch{panel.textContent=zh?'官方账号连接由管理员管理':'Official account connection is managed by an administrator';}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,3 +44,5 @@ details[open]>summary{margin-bottom:16px}
|
|||||||
/* Multiple controls in one field need their own spacing, beyond field margins. */
|
/* Multiple controls in one field need their own spacing, beyond field margins. */
|
||||||
.field > :is(input,select,textarea) + :is(input,select,textarea){margin-top:12px}
|
.field > :is(input,select,textarea) + :is(input,select,textarea){margin-top:12px}
|
||||||
input[type="file"]::file-selector-button{margin-inline-end:12px}
|
input[type="file"]::file-selector-button{margin-inline-end:12px}
|
||||||
|
|
||||||
|
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/* Local Mock only. No real official account or frame writes. */
|
||||||
|
const assert=require('assert'),{chromium}=require('playwright');
|
||||||
|
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const errors=[];let frameWrites=0;page.on('pageerror',e=>errors.push(e.message));await page.route('**/api/**',route=>{const r=route.request();if(r.method()!=='GET'&&!r.url().includes('/auth/')&&!r.url().endsWith('/official/connection')){frameWrites++;return route.abort();}return route.continue();});await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));await page.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.waitForTimeout(300);if(await page.locator('#official-disconnect').count())await page.locator('#official-disconnect').click();await page.locator('#official-username').fill('mock-official-user');await page.locator('#official-password').fill('mock-password-not-saved');await page.locator('#official-login-form button').click();await page.locator('#official-device').waitFor();await page.locator('#official-select-form button').click();await page.locator('.official-account-panel').waitFor();await page.reload();await page.locator('.official-content-info summary').click();await page.locator('#official-disconnect').waitFor();assert((await page.locator('.official-account-panel').textContent()).includes('已关联画框'));for(const width of [1440,390]){await page.setViewportSize({width,height:1000});assert(!await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth));}await page.locator('#official-disconnect').click();await page.locator('#official-login-form').waitFor();assert(await page.locator('#official-password').inputValue()==='');assert(!await page.evaluate(()=>JSON.stringify(localStorage).includes('mock-password-not-saved')));assert(errors.length===0&&frameWrites===0);await browser.close();console.log('Official connection UI: login, discover, select, reload, disconnect, 2 sizes PASS; 0 page errors, 0 frame writes');})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||||
@@ -782,5 +782,63 @@
|
|||||||
"502",
|
"502",
|
||||||
"503"
|
"503"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"/api/v1/devices/{device_id}/official/connection": {
|
||||||
|
"get": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
],
|
||||||
|
"post": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
],
|
||||||
|
"put": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
],
|
||||||
|
"delete": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import json
|
||||||
|
import stat
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from pigway_frame_flow.api.app import create_app
|
||||||
|
|
||||||
|
PATH = "/api/v1/devices/living-room/official/connection"
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_select_persist_and_disconnect(client, settings):
|
||||||
|
response = client.post(
|
||||||
|
PATH, json={"username": "official-user", "password": "not-saved"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["robot_id"] is None
|
||||||
|
assert response.json()["devices"][0]["id"] == 1
|
||||||
|
assert "token" not in response.text and "not-saved" not in response.text
|
||||||
|
path = settings.config_dir / "official-living-room.secret"
|
||||||
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||||
|
assert "not-saved" not in path.read_text()
|
||||||
|
assert client.put(PATH, json={"robot_id": 1}).status_code == 200
|
||||||
|
assert client.put(PATH, json={"robot_id": 999}).status_code == 404
|
||||||
|
restored = create_app(settings, start_worker=False).state.service.registry
|
||||||
|
assert restored.official["living-room"].state().robot_id == 1
|
||||||
|
assert client.delete(PATH).status_code == 200
|
||||||
|
assert json.loads(path.read_text())["token"] == ""
|
||||||
|
assert not client.get(PATH).json()["authorized"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_and_validation_hide_password(client):
|
||||||
|
response = client.post(PATH, json={"username": "", "password": "secret-password"})
|
||||||
|
assert response.status_code == 422 and "secret-password" not in response.text
|
||||||
|
client.headers.clear()
|
||||||
|
assert (
|
||||||
|
client.post(PATH, json={"username": "user", "password": "secret"}).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_connection_only_login_and_read_methods(settings):
|
||||||
|
from pigway_frame_flow.integrations.kokonna_official.connection import Connection
|
||||||
|
from pigway_frame_flow.integrations.kokonna_official.widgets import WidgetReader
|
||||||
|
|
||||||
|
login = Mock(status_code=200)
|
||||||
|
login.json.return_value = {"result": {"token": "official-secret"}}
|
||||||
|
devices = Mock(status_code=200)
|
||||||
|
devices.json.return_value = {
|
||||||
|
"result": {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"robotNickname": "Frame",
|
||||||
|
"model": {"code": "B101", "name": "13.3-inch"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn = Connection(
|
||||||
|
WidgetReader(),
|
||||||
|
settings.config_dir / "official-test.secret",
|
||||||
|
settings.additional_secrets,
|
||||||
|
)
|
||||||
|
with patch("requests.post", side_effect=[login, devices, devices]) as post:
|
||||||
|
conn.login("user", "password")
|
||||||
|
conn.select(10)
|
||||||
|
assert [c.kwargs["json"]["method"] for c in post.call_args_list] == [
|
||||||
|
"app.login",
|
||||||
|
"app.getRobots",
|
||||||
|
"app.getRobots",
|
||||||
|
]
|
||||||
|
assert "official-secret" not in conn.state().model_dump_json()
|
||||||
Reference in New Issue
Block a user