diff --git a/.env.example b/.env.example index f8418ca..efe56b0 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,16 @@ APS_LICENSE_PROVIDER=unconfigured # mock 模式必须显式配置随机签名密钥和租户标识;npm run dev:desktop 会为本地开发自动生成。 # APS_MOCK_LICENSE_SECRET= # APS_MOCK_LICENSE_TENANT_UUID= +# +# ---- 授权码模式(codes:授权码即用户身份,Web/桌面均可激活)---- +# 每个授权码唯一对应一个用户身份,智能体会话、项目、主数据全部绑定到该授权码并互相隔离; +# 同一授权码可在任意浏览器/设备激活并找回同一份数据。 +# 内置超级管理员授权码 aps@Z@Agent@Admin(永久授权 + 全功能角色),其余授权码经 APS_LICENSE_CODES 发放。 +# APS_LICENSE_PROVIDER=codes +# APS_LICENSE_SECRET=至少32个随机字符(HMAC 签名密钥,生产必须独立随机生成) +# APS_LICENSE_TENANT_UUID=platform +# APS_LICENSE_CLIENTS=web,desktop +# APS_LICENSE_CODES={"APS-USER-001":"year","APS-USER-002":{"type":"month","name":"张三"}} # ---- 现场数据目录(可选;未配置时不绑定任何机器目录)---- # KANGNI_DATA_DIR= diff --git a/README.md b/README.md index 3827082..d258756 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,25 @@ Web 端会从 JMS 读取启用企业,用户选择所属企业后使用真实 `APS-HOUR-DEMO`、`APS-DAY-DEMO`、`APS-WEEK-DEMO`、`APS-MONTH-DEMO`、 `APS-YEAR-DEMO`、`APS-PERP-DEMO`。 +### 授权码部署(codes 模式:授权码即用户身份) + +独立 Web 交付(不走 JMS)时可改用授权码认证:用户打开系统即看到"请提供授权码", +激活后智能体会话、项目、主数据全部绑定到该授权码;每个授权码只能看到自己的数据, +同一授权码换浏览器/设备激活可找回同一份数据。配置示例: + +```bash +APS_LICENSE_PROVIDER=codes +APS_LICENSE_SECRET=至少32个随机字符 +APS_LICENSE_CLIENTS=web,desktop +# 发放给用户的一一对应的授权码(类型:hour/day/week/month/year/permanent) +APS_LICENSE_CODES={"APS-USER-001":"year","APS-USER-002":{"type":"month","name":"张三"}} +``` + +内置超级管理员授权码 `aps@Z@Agent@Admin`(永久授权 + 全功能角色),无需配置即可使用; +用管理员码登录后,可在「设置 → 用户与授权」面板直接发放/停用/删除用户授权码 +(登记姓名、手机号、公司、有效时间,系统自动生成授权码),无需重启服务; +`APS_AUTH_PROVIDER=jms` 同时存在时 JMS 优先,授权码模式不生效。 + ```bash # Web(浏览器 http://localhost:5173 ,API :8000) npm run dev:web diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index ae13a0e..ebb9394 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1735,3 +1735,67 @@ export async function postMeshWatchdog(body: { stallTimeoutSec?: number; maxRetr const resp = await apiFetch('/api/mesh/watchdog', { method: 'POST', body: JSON.stringify(body) }); return (await resp.json()) as { error?: string }; } + +// ---------------- 超管 · 用户与授权码管理(codes 模式) ---------------- +export type LicenseCodeActivation = { + status: string; + activatedAt: number; + lastSeenAt: number; + expiresAt: number | null; +}; + +export type LicenseCodeItem = { + id: string; + code: string; + name: string; + mobile: string; + company: string; + licenseType: string; + licenseLabel: string; + expiresAt?: number | null; + status: string; + createdAt: string; + activation: LicenseCodeActivation | null; +}; + +async function readApiError(resp: Response): Promise { + try { + const body = await resp.json() as { detail?: { message?: string } | string; error?: { message?: string } }; + if (typeof body.detail === 'object' && body.detail?.message) return new Error(body.detail.message); + if (typeof body.detail === 'string') return new Error(body.detail); + if (body.error?.message) return new Error(body.error.message); + } catch { /* fall through */ } + return new Error(`请求失败(${resp.status})`); +} + +export async function fetchLicenseCodes(): Promise { + const resp = await apiFetch('/api/admin/license/codes'); + if (!resp.ok) throw await readApiError(resp); + return ((await resp.json()) as { codes: LicenseCodeItem[] }).codes; +} + +export async function createLicenseCode(payload: { + name: string; mobile: string; company: string; licenseType: string; code?: string; expiresAt?: number; +}): Promise { + const resp = await apiFetch('/api/admin/license/codes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) throw await readApiError(resp); + return ((await resp.json()) as { code: LicenseCodeItem }).code; +} + +export async function setLicenseCodeStatus(id: string, status: 'active' | 'disabled'): Promise { + const resp = await apiFetch(`/api/admin/license/codes/${encodeURIComponent(id)}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }); + if (!resp.ok) throw await readApiError(resp); +} + +export async function deleteLicenseCode(id: string): Promise { + const resp = await apiFetch(`/api/admin/license/codes/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!resp.ok) throw await readApiError(resp); +} diff --git a/apps/web/src/auth/AuthEntry.tsx b/apps/web/src/auth/AuthEntry.tsx index 2492de7..eda060c 100644 --- a/apps/web/src/auth/AuthEntry.tsx +++ b/apps/web/src/auth/AuthEntry.tsx @@ -48,7 +48,7 @@ export default function AuthEntry(props: { settings: 'Settings', result: props.hasVisualResult ? 'Open session result' : 'No session result', logout: 'Sign out', }; const displayName = user?.fullname || user?.username || ''; - const desktop = auth.clientMode === 'desktop'; + const licenseMode = auth.clientMode === 'desktop' || auth.licenseMode; const licenseMeta = auth.license ? (auth.license.permanent ? auth.license.label @@ -65,12 +65,12 @@ export default function AuthEntry(props: { className="account-trigger guest-login-trigger" disabled={checking} onClick={props.onLogin} - title={checking ? '正在验证访问状态' : (desktop ? '客户端授权' : '登录')} + title={checking ? '正在验证访问状态' : (licenseMode ? '授权激活' : '登录')} > - {desktop ? : } + {licenseMode ? : } - {checking ? '正在验证' : (desktop ? '授权进入' : '登录')} - {checking ? '请稍候' : (desktop ? '激活后使用本机项目与会话' : '登录后同步项目与会话')} + {checking ? '正在验证' : (licenseMode ? '授权进入' : '登录')} + {checking ? '请稍候' : (licenseMode ? '激活后使用项目与会话' : '登录后同步项目与会话')} {!checking && } @@ -111,7 +111,7 @@ export default function AuthEntry(props: {
{menuNote &&
{menuNote}
}
diff --git a/apps/web/src/auth/AuthGate.tsx b/apps/web/src/auth/AuthGate.tsx index fed18a4..a6c013d 100644 --- a/apps/web/src/auth/AuthGate.tsx +++ b/apps/web/src/auth/AuthGate.tsx @@ -27,7 +27,7 @@ function GuestSidebar(props: { onToggleCollapsed: () => void; }) { const auth = useAuth(); - const accessWord = auth.clientMode === 'desktop' ? '授权' : '登录'; + const accessWord = (auth.clientMode === 'desktop' || auth.licenseMode) ? '授权' : '登录'; const action = props.checking ? undefined : props.onLogin; return (