create-principles-disciple 1.138.0 → 1.138.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/console/dist/ui/api.d.ts +8 -0
- package/console/dist/ui/api.js +20 -4
- package/console/dist/ui/components/auth/login-form.js +118 -12
- package/console/dist/ui/i18n/en.json +26 -9
- package/console/dist/ui/i18n/zh-CN.json +26 -9
- package/console/dist/ui/pages/settings/SettingsPage.js +52 -2
- package/console/dist/web/assets/app.css +6 -0
- package/console/dist/web/assets/app.js +533 -317
- package/package.json +1 -1
- package/pd-cli/package.json +1 -1
- package/release-manager/package.json +1 -1
package/console/dist/ui/api.d.ts
CHANGED
|
@@ -18,6 +18,14 @@ declare function clearToken(): void;
|
|
|
18
18
|
* derived from request data.
|
|
19
19
|
*/
|
|
20
20
|
export declare function shouldRedirectToLoginOnUnauthorized(currentHash: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Staleness guard for 401 responses (PR #1635 review race fix): a 401 may
|
|
23
|
+
* arrive AFTER the login form's restore flow set a newer token while this
|
|
24
|
+
* request was in flight. The stale response must neither erase the newer
|
|
25
|
+
* token nor trigger the login redirect. Pure on the two token snapshots, so
|
|
26
|
+
* the policy is unit-testable without a browser.
|
|
27
|
+
*/
|
|
28
|
+
export declare function shouldInvalidateSessionTokenOn401(requestToken: string | null, storedTokenNow: string | null): boolean;
|
|
21
29
|
/**
|
|
22
30
|
* Send an authenticated request to the Console API.
|
|
23
31
|
*
|
package/console/dist/ui/api.js
CHANGED
|
@@ -23,6 +23,16 @@ function clearToken() {
|
|
|
23
23
|
export function shouldRedirectToLoginOnUnauthorized(currentHash) {
|
|
24
24
|
return !(currentHash === "#/login" || currentHash.startsWith("#/login?"));
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Staleness guard for 401 responses (PR #1635 review race fix): a 401 may
|
|
28
|
+
* arrive AFTER the login form's restore flow set a newer token while this
|
|
29
|
+
* request was in flight. The stale response must neither erase the newer
|
|
30
|
+
* token nor trigger the login redirect. Pure on the two token snapshots, so
|
|
31
|
+
* the policy is unit-testable without a browser.
|
|
32
|
+
*/
|
|
33
|
+
export function shouldInvalidateSessionTokenOn401(requestToken, storedTokenNow) {
|
|
34
|
+
return storedTokenNow === requestToken;
|
|
35
|
+
}
|
|
26
36
|
async function request(path, options, validate) {
|
|
27
37
|
const token = getToken();
|
|
28
38
|
const headers = {
|
|
@@ -44,11 +54,17 @@ async function request(path, options, validate) {
|
|
|
44
54
|
// Any 401 invalidates the session: clear the token even when none was
|
|
45
55
|
// attached (page loaded before the server switched to token mode —
|
|
46
56
|
// PRI-643) and route to login so a token can be entered.
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
57
|
+
// Staleness guard (PR #1635 review): the login form's restore flow may
|
|
58
|
+
// have set a NEWER token while this request was in flight — a late 401
|
|
59
|
+
// from the earlier tokenless probe must not erase it and bounce the
|
|
60
|
+
// just-restored session back to login.
|
|
61
|
+
if (shouldInvalidateSessionTokenOn401(token, getToken())) {
|
|
62
|
+
clearToken();
|
|
50
63
|
// eslint-disable-next-line no-undef
|
|
51
|
-
window.location.hash
|
|
64
|
+
if (shouldRedirectToLoginOnUnauthorized(window.location.hash)) {
|
|
65
|
+
// eslint-disable-next-line no-undef
|
|
66
|
+
window.location.hash = "#/login?session_expired=true";
|
|
67
|
+
}
|
|
52
68
|
}
|
|
53
69
|
}
|
|
54
70
|
let errorMessage = `HTTP ${response.status}`;
|
|
@@ -2,35 +2,141 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { useTranslation } from "react-i18next";
|
|
4
4
|
import { ThemeToggle } from "../theme-toggle.js";
|
|
5
|
-
import { setToken, checkAuth } from "../../api.js";
|
|
5
|
+
import { setToken, clearToken, checkAuth } from "../../api.js";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
function getCompanionBridge() {
|
|
8
|
+
if (typeof window === "undefined")
|
|
9
|
+
return undefined;
|
|
10
|
+
return window.pdCompanion;
|
|
11
|
+
}
|
|
6
12
|
export function LoginForm({ onAuthSuccess }) {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const [phase, setPhase] = React.useState({ kind: "checking" });
|
|
7
15
|
const [token, setTokenInput] = React.useState("");
|
|
8
16
|
const [error, setError] = React.useState(null);
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
// Auto-restore: ask the Companion for the stored credential and connect
|
|
18
|
+
// without user input. Absent companion / absent credential degrade to the
|
|
19
|
+
// manual form; a rejected read degrades to the manual form PLUS an
|
|
20
|
+
// observable diagnostic (rc-9) so version skew is not mistaken for "no
|
|
21
|
+
// credential". Runs once per mount (onAuthSuccess is a stable useCallback
|
|
22
|
+
// in App.tsx); the 重新连接 button re-enters the route to re-run it.
|
|
11
23
|
React.useEffect(() => {
|
|
24
|
+
const companion = getCompanionBridge();
|
|
25
|
+
if (companion === undefined) {
|
|
26
|
+
setPhase({ kind: "form" });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
let cancelled = false;
|
|
30
|
+
void (async () => {
|
|
31
|
+
let status;
|
|
32
|
+
try {
|
|
33
|
+
status = await companion.getConsoleToken();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
if (!cancelled)
|
|
37
|
+
setPhase({ kind: "form-with-read-error" });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (cancelled)
|
|
41
|
+
return;
|
|
42
|
+
if (!status.available || status.token === undefined) {
|
|
43
|
+
setPhase({ kind: "form" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
setPhase({ kind: "connecting" });
|
|
47
|
+
setToken(status.token);
|
|
48
|
+
const valid = await checkAuth();
|
|
49
|
+
if (cancelled)
|
|
50
|
+
return;
|
|
51
|
+
if (valid) {
|
|
52
|
+
onAuthSuccess();
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
// checkAuth's 401 path only cleared the session when it was still
|
|
56
|
+
// this token (api.ts staleness guard).
|
|
57
|
+
setPhase({ kind: "recovery" });
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
return () => {
|
|
61
|
+
cancelled = true;
|
|
62
|
+
};
|
|
63
|
+
}, [onAuthSuccess]);
|
|
64
|
+
// The 401 redirect appends session_expired=true before auto-restore has had
|
|
65
|
+
// a chance to run; that banner would be wrong ("expired") for a fresh
|
|
66
|
+
// browser session with a valid stored credential, so it is only surfaced on
|
|
67
|
+
// the manual-entry form.
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
if (phase.kind !== "form" && phase.kind !== "form-with-read-error")
|
|
70
|
+
return;
|
|
12
71
|
if (window.location.hash.includes("session_expired=true")) {
|
|
13
|
-
setError("
|
|
72
|
+
setError(t("pages.login.errorExpired"));
|
|
14
73
|
}
|
|
15
|
-
}, []);
|
|
74
|
+
}, [phase.kind, t]);
|
|
75
|
+
const persistToCompanion = async (value) => {
|
|
76
|
+
const companion = getCompanionBridge();
|
|
77
|
+
if (companion === undefined)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
const result = await companion.configureConsoleToken(value);
|
|
81
|
+
if (!result.persisted) {
|
|
82
|
+
// rc-9: never fake success — the workspace opens, but the Owner must
|
|
83
|
+
// know the credential was NOT durably saved.
|
|
84
|
+
toast.error(t("pages.login.persistFailed"));
|
|
85
|
+
if (result.nextAction)
|
|
86
|
+
toast.info(result.nextAction, { duration: 8000 });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (result.reason === "external_console_attached") {
|
|
90
|
+
toast.info(t("pages.login.persistAttached"));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
toast.success(t("pages.login.persistSucceeded"));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
toast.error(t("pages.login.persistFailed"));
|
|
98
|
+
}
|
|
99
|
+
};
|
|
16
100
|
const handleSubmit = async (e) => {
|
|
17
101
|
e.preventDefault();
|
|
18
|
-
|
|
102
|
+
const trimmed = token.trim();
|
|
103
|
+
if (!trimmed)
|
|
19
104
|
return;
|
|
20
|
-
setLoading(true);
|
|
21
105
|
setError(null);
|
|
22
|
-
|
|
106
|
+
setPhase({ kind: "connecting" });
|
|
107
|
+
setToken(trimmed);
|
|
23
108
|
const valid = await checkAuth();
|
|
24
109
|
if (valid) {
|
|
110
|
+
await persistToCompanion(trimmed);
|
|
25
111
|
onAuthSuccess();
|
|
26
112
|
}
|
|
27
113
|
else {
|
|
28
|
-
setError("
|
|
29
|
-
|
|
114
|
+
setError(t("pages.login.errorInvalid"));
|
|
115
|
+
setPhase({ kind: "form" });
|
|
30
116
|
}
|
|
31
117
|
};
|
|
32
|
-
|
|
118
|
+
// The detection effect keys on mount, so an honest retry re-enters the
|
|
119
|
+
// route with a clean state instead of duplicating the async body here.
|
|
120
|
+
const retryCompanionConnect = () => {
|
|
121
|
+
setError(null);
|
|
122
|
+
clearToken();
|
|
123
|
+
window.location.reload();
|
|
124
|
+
};
|
|
125
|
+
const showCard = (children) => (_jsxs("div", { className: "min-h-screen flex items-center justify-center bg-paper relative", style: {
|
|
33
126
|
backgroundImage: `linear-gradient(color-mix(in srgb, var(--color-gov) 3.5%, transparent) 1px, transparent 1px), linear-gradient(90deg, color-mix(in srgb, var(--color-gov) 3.5%, transparent) 1px, transparent 1px)`,
|
|
34
127
|
backgroundSize: "32px 32px",
|
|
35
|
-
}, children: [_jsx("div", { className: "absolute top-4 right-4", children: _jsx(ThemeToggle, {}) }), _jsxs("div", { className: "w-full max-w-[400px] mx-4 p-8 bg-surface rounded-[var(--radius-md)] border border-line shadow-card", children: [_jsxs("div", { className: "flex flex-col items-center mb-6", children: [_jsxs("svg", { viewBox: "0 0 28 28", className: "w-12 h-12 text-gov mb-3", fill: "none", stroke: "currentColor", strokeWidth: "1.5", children: [_jsx("path", { d: "M6 4V24M22 4V24M2 14H26", strokeLinecap: "square" }), _jsx("circle", { cx: "14", cy: "14", r: "2.5", fill: "currentColor", stroke: "none" })] }), _jsx("div", { className: "font-mono text-[14px] tracking-[0.16em] font-bold text-ink", children: "PD" }), _jsx("div", { className: "font-mono text-[11px] tracking-[0.14em] text-ink-3 mt-1", children: "GOVERNANCE WORKSPACE" }), _jsx("div", { className: "text-[13px] text-ink-3 mt-3", children: "
|
|
128
|
+
}, children: [_jsx("div", { className: "absolute top-4 right-4", children: _jsx(ThemeToggle, {}) }), _jsxs("div", { className: "w-full max-w-[400px] mx-4 p-8 bg-surface rounded-[var(--radius-md)] border border-line shadow-card", children: [_jsxs("div", { className: "flex flex-col items-center mb-6", children: [_jsxs("svg", { viewBox: "0 0 28 28", className: "w-12 h-12 text-gov mb-3", fill: "none", stroke: "currentColor", strokeWidth: "1.5", children: [_jsx("path", { d: "M6 4V24M22 4V24M2 14H26", strokeLinecap: "square" }), _jsx("circle", { cx: "14", cy: "14", r: "2.5", fill: "currentColor", stroke: "none" })] }), _jsx("div", { className: "font-mono text-[14px] tracking-[0.16em] font-bold text-ink", children: "PD" }), _jsx("div", { className: "font-mono text-[11px] tracking-[0.14em] text-ink-3 mt-1", children: "GOVERNANCE WORKSPACE" }), _jsx("div", { className: "text-[13px] text-ink-3 mt-3", children: t("pages.login.connectWorkspace") })] }), _jsx("div", { className: "border-t border-line my-5" }), children] })] }));
|
|
129
|
+
if (phase.kind === "checking" || phase.kind === "connecting") {
|
|
130
|
+
return showCard(_jsxs("div", { className: "flex flex-col items-center gap-4 py-6", "data-testid": "login-connecting", children: [_jsx("div", { className: "w-8 h-8 border-2 border-gov border-t-transparent rounded-full animate-spin" }), _jsx("span", { className: "text-[13px] text-ink-3", children: phase.kind === "checking" ? t("pages.login.detecting") : t("pages.login.connecting") })] }));
|
|
131
|
+
}
|
|
132
|
+
if (phase.kind === "recovery") {
|
|
133
|
+
return showCard(_jsxs("div", { className: "flex flex-col gap-4", "data-testid": "login-recovery", children: [_jsx("p", { className: "text-[13px] text-amber", children: t("pages.login.recoveryTitle") }), _jsx("p", { className: "text-[13px] text-ink-3 leading-relaxed", children: t("pages.login.recoveryReason") }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("button", { type: "button", onClick: retryCompanionConnect, className: "w-full py-2 px-4 bg-gov text-paper rounded-[var(--radius-sm)] text-[12.5px] font-medium hover:bg-gov-2 transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-gov", children: t("pages.login.reconnect") }), _jsx("button", { type: "button", onClick: () => {
|
|
134
|
+
clearToken();
|
|
135
|
+
setTokenInput("");
|
|
136
|
+
setError(null);
|
|
137
|
+
setPhase({ kind: "form" });
|
|
138
|
+
}, className: "w-full py-2 px-4 border border-line text-ink rounded-[var(--radius-sm)] text-[12.5px] font-medium hover:border-gov transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-gov", children: t("pages.login.manualEntry") })] }), _jsx("p", { className: "text-[12px] text-ink-4 leading-relaxed", children: t("pages.login.manualEntryHint") })] }));
|
|
139
|
+
}
|
|
140
|
+
const showReadError = phase.kind === "form-with-read-error";
|
|
141
|
+
return showCard(_jsxs("form", { onSubmit: handleSubmit, children: [showReadError && (_jsxs("div", { className: "mb-4 border border-amber rounded-[var(--radius-sm)] px-3 py-2", "data-testid": "login-read-error", children: [_jsx("p", { className: "text-[12.5px] font-medium text-amber", children: t("pages.login.readFailedTitle") }), _jsx("p", { className: "text-[12px] text-ink-3 mt-1 leading-relaxed", children: t("pages.login.readFailedHint") })] })), _jsx("label", { htmlFor: "access-token", className: "font-mono text-[11px] tracking-[0.08em] text-ink-3 uppercase mb-2 block", children: t("pages.login.accessToken") }), _jsx("input", { id: "access-token", type: "password", value: token, onChange: (e) => setTokenInput(e.target.value), className: "w-full px-3 py-2 bg-panel border border-line rounded-[var(--radius-sm)] font-mono text-[13px] text-ink focus:border-gov focus:outline-2 focus:outline-offset-2 focus:outline-gov transition-colors", placeholder: t("pages.login.enterAccessToken"), autoFocus: true }), error && _jsx("p", { className: "mt-2 text-[13px] text-amber", children: error }), _jsx("button", { type: "submit", disabled: !token.trim(), className: "mt-4 w-full py-2 px-4 bg-gov text-paper rounded-[var(--radius-sm)] text-[12.5px] font-medium hover:bg-gov-2 disabled:opacity-50 transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-gov", children: t("pages.login.connect") })] }));
|
|
36
142
|
}
|
|
@@ -1054,6 +1054,14 @@
|
|
|
1054
1054
|
"tokenExternalAttached": "Token saved, but an external Console is still connected.",
|
|
1055
1055
|
"tokenSessionOnly": "Token is stored in browser session only.",
|
|
1056
1056
|
"tokenDesktopStored": "Desktop: encrypted with the Windows system store.",
|
|
1057
|
+
"clearToken": "Clear credential",
|
|
1058
|
+
"clearTokenConfirm": "Confirm clear",
|
|
1059
|
+
"tokenCleared": "Credential cleared",
|
|
1060
|
+
"tokenClearedRestarting": "Credential cleared; restarting Console without authentication…",
|
|
1061
|
+
"tokenClearedSession": "Browser session token cleared.",
|
|
1062
|
+
"tokenClearedExternalAttached": "Companion credential cleared, but the externally started Console still requires its token.",
|
|
1063
|
+
"tokenInheritedEnv": "The token comes from the PD_CONSOLE_TOKEN environment variable set outside PD; clearing is not durable — it will return on the next launch. Remove the variable at its source (launcher/terminal).",
|
|
1064
|
+
"tokenClearFailed": "Failed to clear the credential.",
|
|
1057
1065
|
"ownerIdentity": {
|
|
1058
1066
|
"title": "Owner identity",
|
|
1059
1067
|
"intro": "The Owner identity gates governance decision entry points (RuleCode promotion, emergency pause, etc.). Register to ~/.pd/owner.json (install-level, shared across workspaces). If the PD_OWNER_ID and PD_OWNER_CREDENTIAL_ID environment variables are set together, they take precedence as a pair.",
|
|
@@ -1299,15 +1307,24 @@
|
|
|
1299
1307
|
"title": "Login",
|
|
1300
1308
|
"description": "Login to PD Governance Workspace",
|
|
1301
1309
|
"slogan": "Turn repeated corrections into governed principles",
|
|
1302
|
-
"
|
|
1303
|
-
"
|
|
1304
|
-
"
|
|
1305
|
-
"
|
|
1306
|
-
"
|
|
1307
|
-
"
|
|
1308
|
-
"
|
|
1309
|
-
"
|
|
1310
|
-
"
|
|
1310
|
+
"connectWorkspace": "Connect to PD Workspace",
|
|
1311
|
+
"detecting": "Detecting local Companion…",
|
|
1312
|
+
"connecting": "Connecting to PD Workspace…",
|
|
1313
|
+
"accessToken": "Access token",
|
|
1314
|
+
"enterAccessToken": "Enter the access token to connect",
|
|
1315
|
+
"connect": "Connect",
|
|
1316
|
+
"errorInvalid": "Invalid access credential or service not ready. Check and retry.",
|
|
1317
|
+
"errorExpired": "Session expired or token invalid. Please re-enter.",
|
|
1318
|
+
"recoveryTitle": "Cannot connect to PD Workspace",
|
|
1319
|
+
"recoveryReason": "Reason: the stored Owner credential has expired or does not match the local Console.",
|
|
1320
|
+
"reconnect": "Reconnect",
|
|
1321
|
+
"manualEntry": "Enter access token manually",
|
|
1322
|
+
"manualEntryHint": "A manually entered token updates the credential saved in Companion.",
|
|
1323
|
+
"readFailedTitle": "Companion credential read failed",
|
|
1324
|
+
"readFailedHint": "Could not read the credential saved by Companion (possible version mismatch). Please enter the token manually; if this persists, restart Companion.",
|
|
1325
|
+
"persistFailed": "Connected, but the credential was not securely saved — the token will be needed again next time.",
|
|
1326
|
+
"persistSucceeded": "Owner credential securely saved by Companion; the next open will connect automatically.",
|
|
1327
|
+
"persistAttached": "Credential saved, but the Console in use was started externally — restart Companion to apply it.",
|
|
1311
1328
|
"tokenSessionOnly": "Token is stored in browser session only.",
|
|
1312
1329
|
"noAuthFlag": "Use --no-auth flag to disable authentication."
|
|
1313
1330
|
},
|
|
@@ -1054,6 +1054,14 @@
|
|
|
1054
1054
|
"tokenExternalAttached": "令牌已保存,但当前仍连接着外部控制台。",
|
|
1055
1055
|
"tokenSessionOnly": "令牌仅存储在浏览器会话中。",
|
|
1056
1056
|
"tokenDesktopStored": "桌面版:已通过 Windows 系统加密安全保存。",
|
|
1057
|
+
"clearToken": "清除凭证",
|
|
1058
|
+
"clearTokenConfirm": "确认清除",
|
|
1059
|
+
"tokenCleared": "凭证已清除",
|
|
1060
|
+
"tokenClearedRestarting": "凭证已清除,正在重启控制台为免认证模式…",
|
|
1061
|
+
"tokenClearedSession": "浏览器会话令牌已清除。",
|
|
1062
|
+
"tokenClearedExternalAttached": "Companion 凭证已清除,但外部启动的控制台仍要求令牌。",
|
|
1063
|
+
"tokenInheritedEnv": "令牌来自外部设置的 PD_CONSOLE_TOKEN 环境变量,本次清除不会持久——下次启动会恢复。请在其来源处(启动器/终端)删除该变量。",
|
|
1064
|
+
"tokenClearFailed": "清除凭证失败。",
|
|
1057
1065
|
"ownerIdentity": {
|
|
1058
1066
|
"title": "拥有者身份",
|
|
1059
1067
|
"intro": "拥有者身份决定治理决策入口(RuleCode 晋升、紧急暂停等)。登记到 ~/.pd/owner.json(安装级,跨工作区生效)。若环境变量 PD_OWNER_ID 与 PD_OWNER_CREDENTIAL_ID 成对设置,则作为一对整体优先生效。",
|
|
@@ -1299,15 +1307,24 @@
|
|
|
1299
1307
|
"title": "登录",
|
|
1300
1308
|
"description": "登录 PD 治理工作台",
|
|
1301
1309
|
"slogan": "把反复纠正,沉淀为可治理的原则",
|
|
1302
|
-
"
|
|
1303
|
-
"
|
|
1304
|
-
"
|
|
1305
|
-
"
|
|
1306
|
-
"
|
|
1307
|
-
"
|
|
1308
|
-
"
|
|
1309
|
-
"
|
|
1310
|
-
"
|
|
1310
|
+
"connectWorkspace": "连接 PD 工作台",
|
|
1311
|
+
"detecting": "正在检测本机 Companion…",
|
|
1312
|
+
"connecting": "正在连接 PD 工作台…",
|
|
1313
|
+
"accessToken": "访问令牌",
|
|
1314
|
+
"enterAccessToken": "输入访问令牌完成连接",
|
|
1315
|
+
"connect": "连接",
|
|
1316
|
+
"errorInvalid": "访问凭证无效或服务未就绪,请检查后重试。",
|
|
1317
|
+
"errorExpired": "会话已过期或令牌失效,请重新输入。",
|
|
1318
|
+
"recoveryTitle": "无法连接 PD 工作台",
|
|
1319
|
+
"recoveryReason": "原因:拥有者凭证已失效或与本机控制台不匹配。",
|
|
1320
|
+
"reconnect": "重新连接",
|
|
1321
|
+
"manualEntry": "手动输入访问令牌",
|
|
1322
|
+
"manualEntryHint": "手动输入的新令牌会更新 Companion 中保存的凭证。",
|
|
1323
|
+
"readFailedTitle": "Companion 凭证读取失败",
|
|
1324
|
+
"readFailedHint": "无法读取 Companion 保存的凭证(版本可能不匹配)。请手动输入令牌;若持续失败请重启 Companion。",
|
|
1325
|
+
"persistFailed": "已连接,但凭证未能安全保存,下次需重新输入令牌。",
|
|
1326
|
+
"persistSucceeded": "拥有者凭证已由 Companion 安全保存,下次打开将自动连接。",
|
|
1327
|
+
"persistAttached": "凭证已保存,但当前使用的是外部启动的控制台,重启 Companion 后生效。",
|
|
1311
1328
|
"tokenSessionOnly": "令牌仅存储在浏览器会话中。",
|
|
1312
1329
|
"noAuthFlag": "使用 --no-auth 标志禁用身份验证。"
|
|
1313
1330
|
},
|
|
@@ -7,7 +7,7 @@ import { cn } from "../../../lib/utils.js";
|
|
|
7
7
|
import { PageShell } from "../../components/layout/page-shell.js";
|
|
8
8
|
import { PageLoading } from "../../components/layout/page-loading.js";
|
|
9
9
|
import { SectionTitle } from "../../components/layout/section-title.js";
|
|
10
|
-
import { getToken, setToken, fetchWorkspaces, addWorkspace, removeWorkspace, syncWorkspace, fetchOutputLanguage, updateOutputLanguage, fetchConfigSummary, patchFeatureFlag, fetchOwnerIdentity, registerOwnerIdentity, unregisterOwnerIdentity, } from "../../api.js";
|
|
10
|
+
import { getToken, setToken, clearToken, fetchWorkspaces, addWorkspace, removeWorkspace, syncWorkspace, fetchOutputLanguage, updateOutputLanguage, fetchConfigSummary, patchFeatureFlag, fetchOwnerIdentity, registerOwnerIdentity, unregisterOwnerIdentity, } from "../../api.js";
|
|
11
11
|
import { resetOnboardingState } from "../../utils/onboarding-state.js";
|
|
12
12
|
// ── Runtime validators (H section / ERR-001/005/009/013) ─────────────────────
|
|
13
13
|
/** Type guard: is this a non-null object with own properties (not inherited)? */
|
|
@@ -87,6 +87,8 @@ export function SettingsPage() {
|
|
|
87
87
|
const [errorMessage, setErrorMessage] = useState(null);
|
|
88
88
|
// Auth token state
|
|
89
89
|
const [tokenInput, setTokenInput] = useState("");
|
|
90
|
+
// Inline confirm for credential clearing (J.1 pattern, same as workspace removal)
|
|
91
|
+
const [confirmClearToken, setConfirmClearToken] = useState(false);
|
|
90
92
|
// ADR-0022 (PRI-578): Owner identity registration state
|
|
91
93
|
const [ownerIdentity, setOwnerIdentity] = useState(null);
|
|
92
94
|
const [ownerIdInput, setOwnerIdInput] = useState("");
|
|
@@ -241,6 +243,54 @@ export function SettingsPage() {
|
|
|
241
243
|
toast.error(t("pages.settings.tokenSaveFailed"));
|
|
242
244
|
}
|
|
243
245
|
}, [tokenInput, loadOnboardingFlag, t]);
|
|
246
|
+
// Credential lifecycle rollback (SPEC §6.4 清除凭证): drop the durable
|
|
247
|
+
// credential and return the Console to its pre-binding no-auth state. In a
|
|
248
|
+
// plain browser there is nothing durable — only the session cache.
|
|
249
|
+
const handleClearToken = useCallback(async () => {
|
|
250
|
+
setConfirmClearToken(false);
|
|
251
|
+
const companion = getCompanionBridge();
|
|
252
|
+
if (companion === undefined) {
|
|
253
|
+
clearToken();
|
|
254
|
+
setTokenInput("");
|
|
255
|
+
toast.success(t("pages.settings.tokenClearedSession"));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const result = await companion.clearConsoleToken();
|
|
260
|
+
if (!result.cleared) {
|
|
261
|
+
// inherited_env_token: the token comes from the OS environment and
|
|
262
|
+
// would return on the next launch — refuse the fake-durable clear and
|
|
263
|
+
// tell the Owner where the real authority lives (rc-9).
|
|
264
|
+
if (result.reason === "inherited_env_token") {
|
|
265
|
+
toast.info(t("pages.settings.tokenInheritedEnv"));
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
toast.error(t("pages.settings.tokenClearFailed"));
|
|
269
|
+
}
|
|
270
|
+
if (result.nextAction)
|
|
271
|
+
toast.info(result.nextAction, { duration: 8000 });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
clearToken();
|
|
275
|
+
setTokenInput("");
|
|
276
|
+
if (result.reason === "external_console_attached") {
|
|
277
|
+
// Clear-specific message: the credential IS cleared on this side;
|
|
278
|
+
// the external server still enforces its own copy.
|
|
279
|
+
toast.info(t("pages.settings.tokenClearedExternalAttached"));
|
|
280
|
+
}
|
|
281
|
+
else if (result.restartRequested) {
|
|
282
|
+
toast.success(t("pages.settings.tokenClearedRestarting"));
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
toast.success(t("pages.settings.tokenCleared"));
|
|
286
|
+
}
|
|
287
|
+
if (result.nextAction)
|
|
288
|
+
toast.info(result.nextAction, { duration: 8000 });
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
toast.error(t("pages.settings.tokenClearFailed"));
|
|
292
|
+
}
|
|
293
|
+
}, [t]);
|
|
244
294
|
// ── Workspace handlers ─────────────────────────────────────────────────
|
|
245
295
|
const handleAddWorkspace = useCallback(async () => {
|
|
246
296
|
const name = addName.trim();
|
|
@@ -345,7 +395,7 @@ export function SettingsPage() {
|
|
|
345
395
|
return (_jsxs(PageShell, { children: [_jsx("div", { className: "font-mono text-[12px] tracking-[0.14em] text-ink-3 uppercase mb-3", children: t("pages.settings.eyebrow") }), _jsx("h1", { className: "text-[29px] font-semibold tracking-tight text-ink mb-2", children: t("pages.settings.title") }), _jsxs("div", { className: "mt-6 p-4 bg-panel border border-line rounded-[6px] text-ink-2 text-sm", children: [_jsx("p", { children: t("pages.settings.loadError") }), errorMessage && (_jsx("p", { className: "mt-2 text-ink-4 text-[13px] font-mono", children: errorMessage }))] })] }));
|
|
346
396
|
}
|
|
347
397
|
// ── Loaded state ─────────────────────────────────────────────────────────
|
|
348
|
-
return (_jsx(PageShell, { children: _jsxs("div", { className: "animate-[pdFadeIn_400ms_ease-out]", children: [_jsx("div", { className: "font-mono text-[12px] tracking-[0.14em] text-ink-3 uppercase mb-3", children: t("pages.settings.eyebrow") }), _jsx("h1", { className: "text-[29px] font-semibold tracking-tight text-ink mb-2", children: t("pages.settings.title") }), _jsx("p", { className: "text-ink-3 text-[14px] max-w-[760px] leading-relaxed mb-7", children: t("pages.settings.subtitle") }), _jsxs("section", { className: "mb-8", "aria-labelledby": "section-auth", children: [_jsx(SectionTitle, { id: "section-auth", children: t("pages.settings.auth") }), _jsxs("div", { className: "bg-panel border border-line rounded-[6px] p-5", children: [_jsx("label", { htmlFor: "bearer-token-input", className: "block text-sm font-medium text-ink mb-2", children: t("pages.settings.bearerToken") }), _jsx("input", { id: "bearer-token-input", type: "password", value: tokenInput, onChange: (e) => setTokenInput(e.target.value), placeholder: t("pages.settings.enterAccessToken"), className: "w-full border border-line bg-surface rounded-[3px] px-3 py-2 text-sm text-ink focus:outline-none focus:border-gov focus:ring-1 focus:ring-gov" }), _jsxs("div", { className: "flex items-center justify-between mt-3", children: [_jsx("button", { onClick: handleSaveToken, disabled: tokenInput.trim().length === 0, className: "border border-gov bg-gov text-paper rounded-[3px] px-[14px] py-[6px] text-[12.5px] font-medium hover:bg-gov-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: t("common.save") }), _jsx("span", { className: "text-ink-4 text-[12px]", children: getCompanionBridge() === undefined
|
|
398
|
+
return (_jsx(PageShell, { children: _jsxs("div", { className: "animate-[pdFadeIn_400ms_ease-out]", children: [_jsx("div", { className: "font-mono text-[12px] tracking-[0.14em] text-ink-3 uppercase mb-3", children: t("pages.settings.eyebrow") }), _jsx("h1", { className: "text-[29px] font-semibold tracking-tight text-ink mb-2", children: t("pages.settings.title") }), _jsx("p", { className: "text-ink-3 text-[14px] max-w-[760px] leading-relaxed mb-7", children: t("pages.settings.subtitle") }), _jsxs("section", { className: "mb-8", "aria-labelledby": "section-auth", children: [_jsx(SectionTitle, { id: "section-auth", children: t("pages.settings.auth") }), _jsxs("div", { className: "bg-panel border border-line rounded-[6px] p-5", children: [_jsx("label", { htmlFor: "bearer-token-input", className: "block text-sm font-medium text-ink mb-2", children: t("pages.settings.bearerToken") }), _jsx("input", { id: "bearer-token-input", type: "password", value: tokenInput, onChange: (e) => setTokenInput(e.target.value), placeholder: t("pages.settings.enterAccessToken"), className: "w-full border border-line bg-surface rounded-[3px] px-3 py-2 text-sm text-ink focus:outline-none focus:border-gov focus:ring-1 focus:ring-gov" }), _jsxs("div", { className: "flex items-center justify-between mt-3 gap-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { onClick: handleSaveToken, disabled: tokenInput.trim().length === 0, className: "border border-gov bg-gov text-paper rounded-[3px] px-[14px] py-[6px] text-[12.5px] font-medium hover:bg-gov-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: t("common.save") }), confirmClearToken ? (_jsxs(_Fragment, { children: [_jsx("button", { onClick: handleClearToken, className: "border border-amber bg-amber text-ink rounded-[3px] px-[14px] py-[6px] text-[12.5px] font-medium hover:opacity-90 transition-colors focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: t("pages.settings.clearTokenConfirm") }), _jsx("button", { onClick: () => setConfirmClearToken(false), className: "border border-line text-ink rounded-[3px] px-[14px] py-[6px] text-[12.5px] font-medium hover:border-gov transition-colors focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: t("common.cancel") })] })) : (_jsx("button", { onClick: () => setConfirmClearToken(true), className: "border border-line text-ink-3 rounded-[3px] px-[14px] py-[6px] text-[12.5px] font-medium hover:border-gov hover:text-ink transition-colors focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: t("pages.settings.clearToken") }))] }), _jsx("span", { className: "text-ink-4 text-[12px]", children: getCompanionBridge() === undefined
|
|
349
399
|
? t("pages.settings.tokenSessionOnly")
|
|
350
400
|
: t("pages.settings.tokenDesktopStored") })] })] })] }), _jsxs("section", { className: "mb-8", "aria-labelledby": "section-owner-identity", children: [_jsx(SectionTitle, { id: "section-owner-identity", children: t("pages.settings.ownerIdentity.title") }), _jsxs("div", { className: "bg-panel border border-line rounded-[6px] p-5", children: [_jsx("p", { className: "text-ink-3 text-[13px] leading-relaxed mb-3", children: t("pages.settings.ownerIdentity.intro") }), ownerIdentity === null ? (_jsx("div", { className: "text-ink-4 text-[12px]", children: t("pages.settings.ownerIdentity.loading") })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center gap-2 text-[13px] mb-2", "data-testid": "owner-identity-status", children: [_jsx("span", { className: `inline-flex items-center rounded-[2px] px-[7px] py-1 font-mono text-[11px] uppercase ${ownerIdentity.resolved.source === "none"
|
|
351
401
|
? "bg-amber text-ink"
|
|
@@ -1767,6 +1767,9 @@
|
|
|
1767
1767
|
.py-4 {
|
|
1768
1768
|
padding-block: calc(var(--spacing) * 4);
|
|
1769
1769
|
}
|
|
1770
|
+
.py-6 {
|
|
1771
|
+
padding-block: calc(var(--spacing) * 6);
|
|
1772
|
+
}
|
|
1770
1773
|
.py-8 {
|
|
1771
1774
|
padding-block: calc(var(--spacing) * 8);
|
|
1772
1775
|
}
|
|
@@ -2488,6 +2491,9 @@
|
|
|
2488
2491
|
border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);
|
|
2489
2492
|
}
|
|
2490
2493
|
}
|
|
2494
|
+
.hover\:border-gov:hover {
|
|
2495
|
+
border-color: var(--color-gov);
|
|
2496
|
+
}
|
|
2491
2497
|
.hover\:border-line:hover {
|
|
2492
2498
|
border-color: var(--color-line);
|
|
2493
2499
|
}
|