"""Passphrase change: re-wrap one profile's key under a new password.
This screen collects the current passphrase (proof), the replacement, and
its confirmation, then hands all three to the injected rotation door. It
decides nothing about whether a new password is acceptable or whether a
confirmation mismatch refuses the change -- both are re-checked inside
:func:`~cadrumo.application.user_profile.passphrase_rotation
.rotate_profile_passphrase` regardless of what this screen already validated,
per that door's own stated contract ("a caller reaching this function
directly must not be able to skip the check").
See Also:
:func:`~cadrumo.application.user_profile.passphrase_rotation.rotate_profile_passphrase`
The application door this screen drives.
:class:`~cadrumo.entrypoints.tui.secret.registration.RegistrationScreen`
The sibling credential surface this one borrows its live strength
feedback and attempt shape from.
"""
from __future__ import annotations
from contextvars import copy_context
from dataclasses import dataclass
from typing import TYPE_CHECKING, override
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Button, Footer, Input, Label, Static
from ....core.external_constants import UTF_8_ENCODING
from ....core.i18n.render import tr
from ....entrypoints.tui.components.status import PinnedStatusBar
from ....entrypoints.tui.components.theme import BASE_CSS, install_cadrumo_themes, tokenised
from .credentials import (
CREDENTIAL_PANEL_CSS,
CredentialAttempt,
CredentialScreen,
assessment_copy,
)
if TYPE_CHECKING:
from collections.abc import Callable
from ....application.user_profile.passphrase_rotation import ProfilePassphraseRotationOutcome
from ....core.credentials import ProfilePasswordAssessment
from ....domain.calculations.registry.authority_artifact import ProfileDecodeContext
__all__ = [
"PassphraseChangeAttempt",
"PassphraseChangeRefusal",
"PassphraseScreen",
"build_profile_passphrase_change_door",
]
[docs]
@dataclass(frozen=True, slots=True)
class PassphraseChangeRefusal:
"""Secret-free localized refusal retained as data until rendering."""
message_key: str
context: tuple[tuple[str, object], ...] = ()
[docs]
def render(self, *, locale: str | None = None) -> str:
"""Resolve the refusal under the screen's active language."""
return tr(self.message_key, locale=locale, **dict(self.context))
[docs]
@dataclass(frozen=True, slots=True)
class PassphraseChangeAttempt:
"""The outcome of asking the application to rotate a profile's passphrase.
Same shape as :class:`~cadrumo.entrypoints.tui.secret.registration
.RegistrationAttempt`: a refusal arrives as text the screen displays,
not as an exception it has to recognise, keeping refusal classification
with the layer that owns the rotation rules.
"""
outcome: ProfilePassphraseRotationOutcome | None = None
expected_refusal: PassphraseChangeRefusal | None = None
@property
def refusal(self) -> str | None:
"""Render expected refusal data only at the presentation boundary."""
return self.expected_refusal.render() if self.expected_refusal is not None else None
[docs]
class PassphraseScreen(CredentialScreen["ProfilePassphraseRotationOutcome"]):
"""Full-screen credential entry that re-wraps one profile's password."""
SCOPED_CSS = False
DEFAULT_CSS = tokenised(
BASE_CSS
+ CREDENTIAL_PANEL_CSS
+ """
#passphrase-intro { margin: $cadrumo-space-0; }
#passphrase-actions Button { margin: $cadrumo-space-0 $cadrumo-space-0 $cadrumo-space-0 $cadrumo-control-gap; }
#strength-line { margin: $cadrumo-space-0; }
.strength-refused { color: $error; }
.strength-weak { color: $warning; }
.strength-fair { color: $accent; }
.strength-strong { color: $success; }
"""
)
ATTEMPT_NAME = "profile-passphrase-change"
def __init__(
self,
*,
assess: Callable[[str], ProfilePasswordAssessment],
rotate: Callable[[str, str, str], PassphraseChangeAttempt],
) -> None:
"""Bind the password assessment and rotation presentation callbacks."""
super().__init__()
self._assess_profile_password = assess
self._rotate_passphrase = rotate
"""Applies the typed current/new/confirmation triple and returns the
presentation-ready attempt. Injected rather than imported, exactly
like registration's ``register`` door: the profile identity is
already closed over by whatever composed this screen."""
[docs]
@override
def compose(self) -> ComposeResult:
"""Yield the banner, the three credential fields, and the footer."""
yield Static(id="passphrase-banner", classes="cadrumo-banner")
yield PinnedStatusBar(id="credential-status")
with self.credential_panel(panel_id="passphrase-body"):
yield Static(id="passphrase-intro")
yield Label(id="label-current", classes="field-label")
yield Input(id="field-current", password=True)
yield Label(id="label-new", classes="field-label")
yield Static(id="hint-new", classes="field-hint")
yield Input(id="field-new", password=True)
yield Static(id="strength-line")
yield Label(id="label-confirm", classes="field-label")
yield Input(id="field-confirm", password=True)
with Horizontal(id="passphrase-actions", classes="credential-actions"):
yield Button(tr("flows.passphrase.cancel_button"), id="btn-cancel")
yield Button(tr("flows.passphrase.change_button"), id="btn-change", classes="-primary")
yield Footer()
[docs]
def on_mount(self) -> None:
"""Install the theme, render copy, and focus the current-password field."""
install_cadrumo_themes(self.app)
self._render_localised_copy()
self.query_one("#field-current", Input).focus()
def _render_localised_copy(self) -> None:
"""Resolve every operator-facing string on the page."""
title = tr("flows.passphrase.title")
self.title = title
self.sub_title = tr("flows.passphrase.section")
self.query_one("#passphrase-banner", Static).update(title)
self.query_one("#passphrase-intro", Static).update(tr("flows.passphrase.intro"))
self.query_one("#passphrase-body", Vertical).border_title = tr("flows.passphrase.section")
self.query_one("#label-current", Label).update(tr("flows.passphrase.current_label"))
self.query_one("#label-new", Label).update(tr("flows.passphrase.new_label"))
self.query_one("#hint-new", Static).update(tr("flows.passphrase.new_hint"))
self.query_one("#label-confirm", Label).update(tr("flows.passphrase.confirm_label"))
self._render_strength(self.query_one("#field-new", Input).value, assess=self._assess_profile_password)
[docs]
def action_change(self) -> None:
"""Validate the form locally, then rotate the passphrase and exit.
Local checks only catch what the screen can see -- a blank current
password, an unacceptable new one, a mismatched confirmation.
Everything else (a wrong current passphrase, a storage refusal) is
the application door's decision, surfaced here as its translated
message rather than re-derived.
"""
if self.attempt_in_flight:
return
current_passphrase = self.query_one("#field-current", Input).value
new_passphrase = self.query_one("#field-new", Input).value
confirm = self.query_one("#field-confirm", Input).value
if not current_passphrase:
self.refuse(tr("flows.passphrase.refusal.current_required"))
self.query_one("#field-current", Input).focus()
return
assessment = self._assess_profile_password(new_passphrase)
if not assessment.accepted:
self.refuse(assessment_copy(assessment))
self.query_one("#field-new", Input).focus()
return
if new_passphrase != confirm:
self.refuse(tr("flows.passphrase.refusal.confirmation_mismatch"))
self.query_one("#field-confirm", Input).focus()
return
change_context = copy_context()
current_buffer = bytearray(current_passphrase, UTF_8_ENCODING)
new_buffer = bytearray(new_passphrase, UTF_8_ENCODING)
confirm_buffer = bytearray(confirm, UTF_8_ENCODING)
def _rotate() -> CredentialAttempt[ProfilePassphraseRotationOutcome]:
try:
return change_context.run(
self._rotate_passphrase,
current_buffer.decode(UTF_8_ENCODING),
new_buffer.decode(UTF_8_ENCODING),
confirm_buffer.decode(UTF_8_ENCODING),
)
finally:
current_buffer[:] = b"\x00" * len(current_buffer)
new_buffer[:] = b"\x00" * len(new_buffer)
confirm_buffer[:] = b"\x00" * len(confirm_buffer)
self.start_attempt(_rotate)
[docs]
@override
def resolve_attempt_refusal(self, attempt: CredentialAttempt[ProfilePassphraseRotationOutcome]) -> str | None:
"""Render structured rotation refusals under this screen's locale."""
if isinstance(attempt, PassphraseChangeAttempt) and attempt.expected_refusal is not None:
return attempt.expected_refusal.render()
return attempt.refusal
[docs]
@override
def default_refusal(self) -> str:
return tr("flows.passphrase.refusal.change_failed")
[docs]
@override
def progress_message(self) -> str:
return tr("flows.passphrase.change_button")
[docs]
@override
def refuse(self, message: str) -> None:
"""Show refusal and return the keyboard to the form.
The rotation door refuses while the fields are disabled, so re-enabling
them leaves nothing focused. The local checks focus the field they name
immediately after calling this; the current-password field is the
landing place for a refusal that named none, which in practice is the
door rejecting the current password.
"""
super().refuse(message)
self.query_one("#field-current", Input).focus()
[docs]
@override
def set_busy(self, *, busy: bool) -> None:
"""Render change progress and freeze inputs while storage mutates."""
super().set_busy(busy=busy)
for field_id in ("field-current", "field-new", "field-confirm"):
self.query_one(f"#{field_id}", Input).disabled = busy
self.query_one("#btn-change", Button).disabled = busy
self.query_one("#btn-cancel", Button).disabled = busy
[docs]
def build_profile_passphrase_change_door(
profile_id: str,
*,
profile_decode_context: ProfileDecodeContext,
) -> Callable[[str, str, str], PassphraseChangeAttempt]:
"""Bind the canonical rotation door to one already-authenticated profile.
The identity is closed over here so the screen never carries it, and the
returned door mutates nothing until the operator submits. Expected
refusals stay data: the application owns which failures are expected and
supplies their localized key, and no passphrase reaches the result.
"""
from uuid import UUID
from ....application.user_profile.passphrase_rotation import (
ProfilePassphraseRotationError,
rotate_profile_passphrase,
)
parsed_profile_id = UUID(profile_id)
def rotate(current: str, replacement: str, confirmation: str) -> PassphraseChangeAttempt:
try:
outcome = rotate_profile_passphrase(
profile_id=parsed_profile_id,
current_passphrase=current,
new_passphrase=replacement,
new_passphrase_confirmation=confirmation,
profile_decode_context=profile_decode_context,
)
except ProfilePassphraseRotationError as refusal:
if refusal.translated_message is None:
raise
return PassphraseChangeAttempt(
expected_refusal=PassphraseChangeRefusal(
message_key=refusal.translated_message,
context=tuple((refusal.context or {}).items()),
)
)
return PassphraseChangeAttempt(outcome=outcome)
return rotate