fix(shell): daemon-start and health-poll auth handling; policy panel freshness
Some checks failed
Security / Security check (push) Failing after 2s
Some checks failed
Security / Security check (push) Failing after 2s
Review follow-ups on the auth-status work: - Both daemon-start paths classified an auth-rejected hub as "daemon dead" via healthy() and showed a start-failure dialog while the shell banner above correctly blamed the token. They now share daemonAnswers(): only an unreachable probe counts as down. - An auth-rejected poll now re-reads ~/.chain/hub-auth-token and reconnects when the file changed, so a token fixed outside Studio (CLI, editor) heals the connection without a restart — previously the client kept the stale in-memory token forever and the banner's own advice could not work. - An endpoint switch resets the failure streak, so a stale in-flight probe can no longer let the unreachable banner blame the new endpoint for the old one's misses. - The auth-policy panel re-queries when the hub token is saved or cleared in the panel above (reloadTick), instead of keeping a stale admin-denied hint; it also renders the hub's new reload_required flag as a pending-reload warning (DE+EN). - today-pipeline.md still documented ~/.fai/today after the rename; the FAB theme comment now states the both-themes intent. flutter analyze clean; 71 tests green including four new ones. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
2a1cbc82c3
commit
7cc8bab9b9
13 changed files with 242 additions and 16 deletions
|
|
@ -24,11 +24,11 @@ feeds the accepted story straight into Studio without a code release.
|
||||||
│ 2. PROPOSER tools/today/propose.sh │
|
│ 2. PROPOSER tools/today/propose.sh │
|
||||||
│ local System-AI (Ollama by default) drafts 3 story candidates │
|
│ local System-AI (Ollama by default) drafts 3 story candidates │
|
||||||
│ using tools/today/prompt.template.md + the signal summary → │
|
│ using tools/today/prompt.template.md + the signal summary → │
|
||||||
│ ~/.fai/today/proposals/<ISO-DATE>-<n>.yaml │
|
│ ~/.chain/today/proposals/<ISO-DATE>-<n>.yaml │
|
||||||
│ │
|
│ │
|
||||||
│ 3. REVIEW + ACCEPT tools/today/accept.sh <proposal-id> │
|
│ 3. REVIEW + ACCEPT tools/today/accept.sh <proposal-id> │
|
||||||
│ operator skims the proposal files, picks one (or none) → │
|
│ operator skims the proposal files, picks one (or none) → │
|
||||||
│ ~/.fai/today/active.yaml │
|
│ ~/.chain/today/active.yaml │
|
||||||
│ │
|
│ │
|
||||||
│ 4. STUDIO lib/pages/store.dart │
|
│ 4. STUDIO lib/pages/store.dart │
|
||||||
│ Studio reads active.yaml at startup; falls back to the │
|
│ Studio reads active.yaml at startup; falls back to the │
|
||||||
|
|
@ -43,7 +43,7 @@ agent (macOS), systemd timer (Linux), or `cron` — see
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
`~/.fai/today/active.yaml` (and every proposal file) follows this shape:
|
`~/.chain/today/active.yaml` (and every proposal file) follows this shape:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
schema: today/v1
|
schema: today/v1
|
||||||
|
|
@ -111,6 +111,6 @@ The prompt template (`tools/today/prompt.template.md`) hard-encodes:
|
||||||
- A/B-testing or click-through tracking — Ch∆In has no surveillance budget
|
- A/B-testing or click-through tracking — Ch∆In has no surveillance budget
|
||||||
and no telemetry pipeline to feed it into.
|
and no telemetry pipeline to feed it into.
|
||||||
- Auto-publication to git (operator-curated boundary stays manual).
|
- Auto-publication to git (operator-curated boundary stays manual).
|
||||||
- Hub-served stories. Studio reads `~/.fai/today/active.yaml` directly
|
- Hub-served stories. Studio reads `~/.chain/today/active.yaml` directly
|
||||||
for now; promoting this to a HubAdmin RPC is a Phase 1 question once
|
for now; promoting this to a HubAdmin RPC is a Phase 1 question once
|
||||||
multi-tenant Studio appears.
|
multi-tenant Studio appears.
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,7 @@ class HubService {
|
||||||
? await HubAuthToken.read()
|
? await HubAuthToken.read()
|
||||||
: authToken as String?;
|
: authToken as String?;
|
||||||
_client = HubClient(endpoint: endpoint, authToken: token);
|
_client = HubClient(endpoint: endpoint, authToken: token);
|
||||||
|
_lastAuthToken = token;
|
||||||
if (persist) {
|
if (persist) {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_kHostKey, endpoint.host);
|
await prefs.setString(_kHostKey, endpoint.host);
|
||||||
|
|
@ -177,6 +178,23 @@ class HubService {
|
||||||
await reconnect(_client.endpoint);
|
await reconnect(_client.endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The token the live client was built with. The client caches it,
|
||||||
|
/// so an edit to `~/.chain/hub-auth-token` outside Studio is
|
||||||
|
/// invisible until a reconnect — see [reloadAuthTokenIfChanged].
|
||||||
|
String? _lastAuthToken;
|
||||||
|
|
||||||
|
/// Re-read the token file and reconnect ONLY when its content
|
||||||
|
/// differs from the token the live client uses. Returns whether a
|
||||||
|
/// reconnect happened. The shell's health poll calls this on an
|
||||||
|
/// auth-rejected probe so a token fixed outside Studio (CLI,
|
||||||
|
/// editor) heals the connection without a restart.
|
||||||
|
Future<bool> reloadAuthTokenIfChanged() async {
|
||||||
|
final fresh = await HubAuthToken.read();
|
||||||
|
if (fresh == _lastAuthToken) return false;
|
||||||
|
await reconnect(_client.endpoint, authToken: fresh, persist: false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static const _kThemeKey = 'theme.mode';
|
static const _kThemeKey = 'theme.mode';
|
||||||
static const _kLocaleKey = 'locale.code';
|
static const _kLocaleKey = 'locale.code';
|
||||||
|
|
||||||
|
|
@ -363,6 +381,7 @@ class HubService {
|
||||||
scopeClaim: r.jwt.scopeClaim,
|
scopeClaim: r.jwt.scopeClaim,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
reloadRequired: r.reloadRequired,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1685,11 +1704,16 @@ class HubAuthPolicy {
|
||||||
/// jwt-rs256 parameters; null for the static validator.
|
/// jwt-rs256 parameters; null for the static validator.
|
||||||
final HubJwtValidatorInfo? jwt;
|
final HubJwtValidatorInfo? jwt;
|
||||||
|
|
||||||
|
/// True when the on-disk auth config (or its env vars) no longer
|
||||||
|
/// matches the validator the hub enforces — a pending reload.
|
||||||
|
final bool reloadRequired;
|
||||||
|
|
||||||
const HubAuthPolicy({
|
const HubAuthPolicy({
|
||||||
required this.validator,
|
required this.validator,
|
||||||
required this.anonymousAllowed,
|
required this.anonymousAllowed,
|
||||||
required this.tokens,
|
required this.tokens,
|
||||||
this.jwt,
|
this.jwt,
|
||||||
|
this.reloadRequired = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -835,6 +835,7 @@
|
||||||
"authPolicyValidatorStatic": "Prüfverfahren: statische Token-Liste",
|
"authPolicyValidatorStatic": "Prüfverfahren: statische Token-Liste",
|
||||||
"authPolicyValidatorJwt": "Prüfverfahren: JWT (RS256) über eine externe Identitätsstelle",
|
"authPolicyValidatorJwt": "Prüfverfahren: JWT (RS256) über eine externe Identitätsstelle",
|
||||||
"authPolicyAnonymous": "Keine Tokens konfiguriert — der Hub akzeptiert anonyme Aufrufe. Für lokales Arbeiten in Ordnung; für den Produktivbetrieb Tokens in ~/.chain/config.yaml einrichten.",
|
"authPolicyAnonymous": "Keine Tokens konfiguriert — der Hub akzeptiert anonyme Aufrufe. Für lokales Arbeiten in Ordnung; für den Produktivbetrieb Tokens in ~/.chain/config.yaml einrichten.",
|
||||||
|
"authPolicyReloadRequired": "Die Konfigurationsdatei (oder eine Umgebungsvariable) wurde seit dem Laden geändert — der Hub erzwingt noch den alten Stand. Unten „Neu laden“ wählen, damit die Änderung wirkt.",
|
||||||
"authPolicyNeedsAdmin": "Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.",
|
"authPolicyNeedsAdmin": "Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.",
|
||||||
"authPolicyHubTooOld": "Der verbundene Hub kennt diese Ansicht noch nicht — er ist älter als Studio. Aktualisieren Sie den Hub (chain update apply) und laden Sie neu.",
|
"authPolicyHubTooOld": "Der verbundene Hub kennt diese Ansicht noch nicht — er ist älter als Studio. Aktualisieren Sie den Hub (chain update apply) und laden Sie neu.",
|
||||||
"authPolicyRetry": "Erneut versuchen",
|
"authPolicyRetry": "Erneut versuchen",
|
||||||
|
|
|
||||||
|
|
@ -853,6 +853,7 @@
|
||||||
"authPolicyValidatorStatic": "Validator: static token list",
|
"authPolicyValidatorStatic": "Validator: static token list",
|
||||||
"authPolicyValidatorJwt": "Validator: JWT (RS256) via an external identity provider",
|
"authPolicyValidatorJwt": "Validator: JWT (RS256) via an external identity provider",
|
||||||
"authPolicyAnonymous": "No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.",
|
"authPolicyAnonymous": "No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.",
|
||||||
|
"authPolicyReloadRequired": "The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.",
|
||||||
"authPolicyNeedsAdmin": "This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.",
|
"authPolicyNeedsAdmin": "This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.",
|
||||||
"authPolicyHubTooOld": "The connected hub does not know this view yet — it is older than Studio. Update the hub (chain update apply) and reload.",
|
"authPolicyHubTooOld": "The connected hub does not know this view yet — it is older than Studio. Update the hub (chain update apply) and reload.",
|
||||||
"authPolicyRetry": "Retry",
|
"authPolicyRetry": "Retry",
|
||||||
|
|
|
||||||
|
|
@ -2654,6 +2654,12 @@ abstract class AppLocalizations {
|
||||||
/// **'No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.'**
|
/// **'No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.'**
|
||||||
String get authPolicyAnonymous;
|
String get authPolicyAnonymous;
|
||||||
|
|
||||||
|
/// No description provided for @authPolicyReloadRequired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.'**
|
||||||
|
String get authPolicyReloadRequired;
|
||||||
|
|
||||||
/// No description provided for @authPolicyNeedsAdmin.
|
/// No description provided for @authPolicyNeedsAdmin.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1514,6 +1514,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get authPolicyAnonymous =>
|
String get authPolicyAnonymous =>
|
||||||
'Keine Tokens konfiguriert — der Hub akzeptiert anonyme Aufrufe. Für lokales Arbeiten in Ordnung; für den Produktivbetrieb Tokens in ~/.chain/config.yaml einrichten.';
|
'Keine Tokens konfiguriert — der Hub akzeptiert anonyme Aufrufe. Für lokales Arbeiten in Ordnung; für den Produktivbetrieb Tokens in ~/.chain/config.yaml einrichten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authPolicyReloadRequired =>
|
||||||
|
'Die Konfigurationsdatei (oder eine Umgebungsvariable) wurde seit dem Laden geändert — der Hub erzwingt noch den alten Stand. Unten „Neu laden“ wählen, damit die Änderung wirkt.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get authPolicyNeedsAdmin =>
|
String get authPolicyNeedsAdmin =>
|
||||||
'Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.';
|
'Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.';
|
||||||
|
|
|
||||||
|
|
@ -1527,6 +1527,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get authPolicyAnonymous =>
|
String get authPolicyAnonymous =>
|
||||||
'No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.';
|
'No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authPolicyReloadRequired =>
|
||||||
|
'The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get authPolicyNeedsAdmin =>
|
String get authPolicyNeedsAdmin =>
|
||||||
'This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.';
|
'This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.';
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,11 @@ class StudioShellState extends State<StudioShell> {
|
||||||
int _failedPolls = 0;
|
int _failedPolls = 0;
|
||||||
static const int _unreachableThreshold = 3;
|
static const int _unreachableThreshold = 3;
|
||||||
|
|
||||||
|
/// Endpoint the previous poll ran against; a change resets the
|
||||||
|
/// failure streak so stale in-flight results never blame the new
|
||||||
|
/// endpoint (see `_checkHealth`).
|
||||||
|
String _polledEndpoint = '';
|
||||||
|
|
||||||
/// Whether to render the unreachable banner. Distinct from
|
/// Whether to render the unreachable banner. Distinct from
|
||||||
/// `_connected == false` so a single transient miss doesn't
|
/// `_connected == false` so a single transient miss doesn't
|
||||||
/// flash the banner — only sustained failure does.
|
/// flash the banner — only sustained failure does.
|
||||||
|
|
@ -287,6 +292,44 @@ class StudioShellState extends State<StudioShell> {
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static Future<HubProbeResult> Function()? debugProbeOverride;
|
static Future<HubProbeResult> Function()? debugProbeOverride;
|
||||||
|
|
||||||
|
/// Test-only: replaces the token-file self-heal check (see
|
||||||
|
/// [_reloadTokenIfChanged]) so widget tests can assert the
|
||||||
|
/// auth-rejected poll re-reads the token without touching disk.
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<bool> Function()? debugReloadTokenOverride;
|
||||||
|
|
||||||
|
/// Whether the hub daemon answers at all — an auth rejection or a
|
||||||
|
/// not-serving health state still means the process is alive, just
|
||||||
|
/// not usable yet; only [HubProbeResult.unreachable] is "down".
|
||||||
|
/// Used by the daemon-start paths so a wrong token doesn't
|
||||||
|
/// misreport as "daemon start failed" (the shell banner already
|
||||||
|
/// explains the token problem).
|
||||||
|
static Future<bool> daemonAnswers() async {
|
||||||
|
try {
|
||||||
|
final probe = debugProbeOverride != null
|
||||||
|
? await debugProbeOverride!()
|
||||||
|
: await HubService.instance.probeHealth();
|
||||||
|
return probe != HubProbeResult.unreachable;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Self-heal after an auth-rejected poll: the operator may have
|
||||||
|
/// fixed `~/.chain/hub-auth-token` outside Studio (CLI, editor) —
|
||||||
|
/// the client caches the token, so re-read the file and reconnect
|
||||||
|
/// when it changed. Returns whether a reconnect happened. Inert
|
||||||
|
/// when the probe is test-overridden, unless the reload override
|
||||||
|
/// is set too.
|
||||||
|
static Future<bool> _reloadTokenIfChanged() {
|
||||||
|
final override = debugReloadTokenOverride;
|
||||||
|
if (override != null) return override();
|
||||||
|
if (debugProbeOverride != null) return Future.value(false);
|
||||||
|
return HubService.instance
|
||||||
|
.reloadAuthTokenIfChanged()
|
||||||
|
.catchError((_) => false);
|
||||||
|
}
|
||||||
|
|
||||||
/// Current connection state, for descendants (e.g. WelcomePage)
|
/// Current connection state, for descendants (e.g. WelcomePage)
|
||||||
/// that adapt their content to hub availability.
|
/// that adapt their content to hub availability.
|
||||||
bool? get connected => _connected;
|
bool? get connected => _connected;
|
||||||
|
|
@ -307,9 +350,9 @@ class StudioShellState extends State<StudioShell> {
|
||||||
}
|
}
|
||||||
// The start command reported failure — but very often the daemon is
|
// The start command reported failure — but very often the daemon is
|
||||||
// simply already running (port in use). Probe before crying error,
|
// simply already running (port in use). Probe before crying error,
|
||||||
// so "tap to start" on an already-up hub just connects.
|
// so "tap to start" on an already-up hub just connects. Alive but
|
||||||
final alive =
|
// auth-rejected counts as running — the banner explains the token.
|
||||||
await HubService.instance.healthy().catchError((_) => false);
|
final alive = await daemonAnswers();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (alive) {
|
if (alive) {
|
||||||
await _checkHealth();
|
await _checkHealth();
|
||||||
|
|
@ -422,15 +465,22 @@ class StudioShellState extends State<StudioShell> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkHealth() async {
|
Future<void> _checkHealth({bool retriedAfterTokenReload = false}) async {
|
||||||
final probe = debugProbeOverride != null
|
final probe = debugProbeOverride != null
|
||||||
? await debugProbeOverride!()
|
? await debugProbeOverride!()
|
||||||
: await HubService.instance.probeHealth();
|
: await HubService.instance.probeHealth();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// A failure that raced an endpoint switch (Settings save, channel
|
||||||
|
// switch) must not inherit the old endpoint's failure streak —
|
||||||
|
// restart the count so the banner never blames the NEW endpoint
|
||||||
|
// for the OLD one's misses.
|
||||||
|
final endpoint = HubService.instance.endpointLabel;
|
||||||
|
final endpointChanged = endpoint != _polledEndpoint;
|
||||||
|
_polledEndpoint = endpoint;
|
||||||
final ok = probe == HubProbeResult.serving;
|
final ok = probe == HubProbeResult.serving;
|
||||||
final authRejected = probe == HubProbeResult.authRejected;
|
final authRejected = probe == HubProbeResult.authRejected;
|
||||||
final wasUnreachable = _hubUnreachable;
|
final wasUnreachable = _hubUnreachable;
|
||||||
final nextFailed = ok ? 0 : _failedPolls + 1;
|
final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1);
|
||||||
final connectionChanged = _connected != ok;
|
final connectionChanged = _connected != ok;
|
||||||
final bannerChanged =
|
final bannerChanged =
|
||||||
wasUnreachable != (nextFailed >= _unreachableThreshold);
|
wasUnreachable != (nextFailed >= _unreachableThreshold);
|
||||||
|
|
@ -444,6 +494,16 @@ class StudioShellState extends State<StudioShell> {
|
||||||
_authRejected = authRejected;
|
_authRejected = authRejected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (authRejected && !retriedAfterTokenReload) {
|
||||||
|
// Self-heal: re-read the token file in case it was fixed
|
||||||
|
// outside Studio; on a real change reconnect + re-probe now
|
||||||
|
// instead of waiting out the poll interval. One retry per
|
||||||
|
// poll tick at most.
|
||||||
|
final changed = await _reloadTokenIfChanged();
|
||||||
|
if (changed && mounted) {
|
||||||
|
return _checkHealth(retriedAfterTokenReload: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
try {
|
try {
|
||||||
final snap = await HubService.instance.channelStatus();
|
final snap = await HubService.instance.channelStatus();
|
||||||
|
|
@ -779,8 +839,9 @@ class _SidebarState extends State<_Sidebar>
|
||||||
}
|
}
|
||||||
// The daemon may already be running (port in use) — probe before
|
// The daemon may already be running (port in use) — probe before
|
||||||
// showing an error, so this just connects on an already-up hub.
|
// showing an error, so this just connects on an already-up hub.
|
||||||
final alive =
|
// Alive but auth-rejected counts as running — the shell banner
|
||||||
await HubService.instance.healthy().catchError((_) => false);
|
// explains the token problem.
|
||||||
|
final alive = await StudioShellState.daemonAnswers();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (alive) return;
|
if (alive) return;
|
||||||
// Genuinely down: persistent, copyable dialog (a SnackBar flashes
|
// Genuinely down: persistent, copyable dialog (a SnackBar flashes
|
||||||
|
|
|
||||||
|
|
@ -265,9 +265,11 @@ class ChainTheme {
|
||||||
),
|
),
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
// Match FABs to the filled-button accent: Material 3 defaults
|
// Match FABs to the filled-button accent in BOTH themes:
|
||||||
// them to primaryContainer, which reads as a washed-out tonal
|
// Material 3 defaults them to primaryContainer, a tonal
|
||||||
// surface next to the sky-700 CTAs in the light theme.
|
// surface that reads washed-out next to the primary CTAs
|
||||||
|
// (visually verified light and dark; the dark FAB follows
|
||||||
|
// the dark filled buttons on purpose).
|
||||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
backgroundColor: scheme.primary,
|
backgroundColor: scheme.primary,
|
||||||
foregroundColor: scheme.onPrimary,
|
foregroundColor: scheme.onPrimary,
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
||||||
/// after save / clear; UI only ever sees the trimmed length.
|
/// after save / clear; UI only ever sees the trimmed length.
|
||||||
int? _hubAuthTokenChars;
|
int? _hubAuthTokenChars;
|
||||||
|
|
||||||
|
/// Bumped whenever the hub auth token is saved or cleared so the
|
||||||
|
/// auth-policy panel below re-queries with the new credentials.
|
||||||
|
int _authPolicyReloadTick = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -95,6 +99,9 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
||||||
await HubService.instance.reloadAuthToken();
|
await HubService.instance.reloadAuthToken();
|
||||||
await _loadHubAuthToken();
|
await _loadHubAuthToken();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// The connection's auth just changed — the policy panel below
|
||||||
|
// must re-query instead of keeping a stale admin-denied hint.
|
||||||
|
setState(() => _authPolicyReloadTick++);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
|
|
@ -111,6 +118,7 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
||||||
await HubService.instance.reloadAuthToken();
|
await HubService.instance.reloadAuthToken();
|
||||||
await _loadHubAuthToken();
|
await _loadHubAuthToken();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
setState(() => _authPolicyReloadTick++);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
|
|
@ -650,7 +658,7 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
||||||
onClear: _clearHubAuthToken,
|
onClear: _clearHubAuthToken,
|
||||||
),
|
),
|
||||||
const SizedBox(height: ChainSpace.xl),
|
const SizedBox(height: ChainSpace.xl),
|
||||||
const HubAuthPolicyPanel(),
|
HubAuthPolicyPanel(reloadTick: _authPolicyReloadTick),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,18 @@ class HubAuthPolicyPanel extends StatefulWidget {
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
final Future<int> Function()? reloader;
|
final Future<int> Function()? reloader;
|
||||||
|
|
||||||
const HubAuthPolicyPanel({super.key, this.loader, this.reloader});
|
/// Bumped by the parent whenever an adjacent action changed the
|
||||||
|
/// connection's auth state (token saved or cleared in the panel
|
||||||
|
/// above) — the policy view reloads instead of showing a stale
|
||||||
|
/// "store an admin token" hint next to the just-stored token.
|
||||||
|
final int reloadTick;
|
||||||
|
|
||||||
|
const HubAuthPolicyPanel({
|
||||||
|
super.key,
|
||||||
|
this.loader,
|
||||||
|
this.reloader,
|
||||||
|
this.reloadTick = 0,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HubAuthPolicyPanel> createState() => _HubAuthPolicyPanelState();
|
State<HubAuthPolicyPanel> createState() => _HubAuthPolicyPanelState();
|
||||||
|
|
@ -42,6 +53,14 @@ class _HubAuthPolicyPanelState extends State<HubAuthPolicyPanel> {
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant HubAuthPolicyPanel oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.reloadTick != oldWidget.reloadTick) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
|
|
@ -193,6 +212,15 @@ class _HubAuthPolicyPanelState extends State<HubAuthPolicyPanel> {
|
||||||
color: theme.colorScheme.tertiary,
|
color: theme.colorScheme.tertiary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (p.reloadRequired) ...[
|
||||||
|
const SizedBox(height: ChainSpace.xs),
|
||||||
|
_hintRow(
|
||||||
|
theme,
|
||||||
|
Icons.sync_problem_outlined,
|
||||||
|
l.authPolicyReloadRequired,
|
||||||
|
color: theme.colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
],
|
||||||
if (p.tokens.isNotEmpty) ...[
|
if (p.tokens.isNotEmpty) ...[
|
||||||
const SizedBox(height: ChainSpace.sm),
|
const SizedBox(height: ChainSpace.sm),
|
||||||
for (final t in p.tokens) _tokenRow(theme, l, t),
|
for (final t in p.tokens) _tokenRow(theme, l, t),
|
||||||
|
|
|
||||||
|
|
@ -156,4 +156,54 @@ void main() {
|
||||||
expect(find.textContaining('admin-Recht'), findsOneWidget);
|
expect(find.textContaining('admin-Recht'), findsOneWidget);
|
||||||
expect(find.text('Erneut versuchen'), findsOneWidget);
|
expect(find.text('Erneut versuchen'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('reload-required policy shows the pending-reload hint', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(
|
||||||
|
HubAuthPolicyPanel(
|
||||||
|
loader: () async => const HubAuthPolicy(
|
||||||
|
validator: 'static',
|
||||||
|
anonymousAllowed: false,
|
||||||
|
tokens: [],
|
||||||
|
reloadRequired: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.textContaining('erzwingt noch den alten Stand'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a bumped reloadTick re-queries the policy', (tester) async {
|
||||||
|
var loads = 0;
|
||||||
|
Widget hostWithTick(int tick) => _host(
|
||||||
|
HubAuthPolicyPanel(
|
||||||
|
loader: () async {
|
||||||
|
loads++;
|
||||||
|
return _staticPolicy();
|
||||||
|
},
|
||||||
|
reloadTick: tick,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(hostWithTick(0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(loads, 1);
|
||||||
|
|
||||||
|
// Same tick: no reload on unrelated rebuilds.
|
||||||
|
await tester.pumpWidget(hostWithTick(0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(loads, 1);
|
||||||
|
|
||||||
|
// Bumped tick (token saved next door): the panel re-queries.
|
||||||
|
await tester.pumpWidget(hostWithTick(1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(loads, 2);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ Future<void> _pumpShell(WidgetTester tester) async {
|
||||||
void main() {
|
void main() {
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
StudioShellState.debugProbeOverride = null;
|
StudioShellState.debugProbeOverride = null;
|
||||||
|
StudioShellState.debugReloadTokenOverride = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('sustained auth rejection points at the token, not the wire',
|
testWidgets('sustained auth rejection points at the token, not the wire',
|
||||||
|
|
@ -60,4 +61,40 @@ void main() {
|
||||||
expect(find.textContaining("Can't reach"), findsOneWidget);
|
expect(find.textContaining("Can't reach"), findsOneWidget);
|
||||||
expect(find.textContaining('rejected the sign-in'), findsNothing);
|
expect(find.textContaining('rejected the sign-in'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('auth-rejected poll re-reads the token file and re-probes',
|
||||||
|
(tester) async {
|
||||||
|
// First probe(s) rejected; after the token file "changes" the
|
||||||
|
// immediate re-probe serves — the operator fixed the token via
|
||||||
|
// CLI/editor and Studio healed without waiting a poll cycle.
|
||||||
|
var probes = 0;
|
||||||
|
var reloads = 0;
|
||||||
|
StudioShellState.debugProbeOverride = () async {
|
||||||
|
probes++;
|
||||||
|
return reloads > 0 ? HubProbeResult.serving : HubProbeResult.authRejected;
|
||||||
|
};
|
||||||
|
StudioShellState.debugReloadTokenOverride = () async {
|
||||||
|
reloads++;
|
||||||
|
return true; // token file content changed → reconnected
|
||||||
|
};
|
||||||
|
await _pumpShell(tester);
|
||||||
|
|
||||||
|
expect(reloads, greaterThanOrEqualTo(1));
|
||||||
|
// The self-heal re-probe ran within the same tick (probes >
|
||||||
|
// reloads means at least one immediate retry happened) and the
|
||||||
|
// banner never surfaces.
|
||||||
|
expect(probes, greaterThan(reloads));
|
||||||
|
expect(find.textContaining('rejected the sign-in'), findsNothing);
|
||||||
|
expect(find.textContaining("Can't reach"), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('daemonAnswers treats auth-rejected as alive', (tester) async {
|
||||||
|
StudioShellState.debugProbeOverride =
|
||||||
|
() async => HubProbeResult.authRejected;
|
||||||
|
expect(await StudioShellState.daemonAnswers(), isTrue);
|
||||||
|
|
||||||
|
StudioShellState.debugProbeOverride =
|
||||||
|
() async => HubProbeResult.unreachable;
|
||||||
|
expect(await StudioShellState.daemonAnswers(), isFalse);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue