Commit Graph

5 Commits

Author SHA1 Message Date
ab7bce9f5b T10.6 D13 - strip the password UI; "Forgot password?" goes to Okta
login.html/login.js: the two reset views are gone along with the reset-token
handling, and the sign-in form now says which password to type - "your Windows
password, the same one you use to sign in to your computer" - using the .hint
class the page already had, so no new CSS and no new literal.

"Forgot password?" is KEPT and points at https://primecontrols.okta.com/. An
earlier draft of this task deleted the link and I proposed a plain "contact IT"
sentence instead; Okta is the better answer, and with no app password and no
break-glass it is the only recovery path that exists.

Three details that would each have broken it:

- The old click handler on #forgot-link called preventDefault() to swap views.
  Left in place it would have silently swallowed the navigation, so the link
  would look right and do nothing. There is now deliberately no handler, and
  login.js says why so nobody adds one back.
- target="_blank" without rel="noopener noreferrer" hands the opened page a
  window.opener handle back to the login page.
- Worth recording since it was checked rather than assumed: the CSP allows
  this. form-action 'self' governs form submission, not link navigation, and no
  navigate-to directive is set - so a plain <a href> off-origin is fine and the
  nginx config needs no change.

login.js also handles 503 distinctly now. T10.2 made that mean "the directory
is unreachable or misconfigured", which is our fault - showing "invalid
password" would send people hunting for a password they no longer have while a
deploy is broken.

Also removed, because T10.3 deleted the endpoints behind them and leaving them
would have produced visible 404s rather than dead-but-harmless markup:

  auth-guard.js   the whole change-password dialog (POST /api/auth/password)
  wp-sidenav.js   the "Password / Change your password" menu entry that opened it
  users.js        the per-row "Reset password" action
  users.js        the password field in the create-account form - NewUserIn no
  users.html      longer accepts one, so the form was posting a rejected field

The self-row placeholder button pointed at a top-bar Password link that no
longer exists; it is now a plain "you" marker.

Verified: node --check passes on all four touched JS files; the only password
references left in html/ are the sign-in form and the SMTP config in admin.js,
which is unrelated and stays.

Logged BL-027 rather than acted on: the Okta URL is the first sign of an Okta
tenant on this estate, which means an OIDC flow is available in principle and
would remove the domain-lockout hazard that forced AUTH_MAX_ATTEMPTS to 2. D13
was decided and reaffirmed and T10.1-T10.4 are built, so swapping the mechanism
mid-wave is the reordering CLAUDE.md forbids. Recording is not reopening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:30:16 -05:00
560f0cb3cc BL-024 - the last 21 native dialogs, onto the shared kit
S1 counted 79 native dialogs app-wide and its tasks removed 58; the audit
found the rest on surfaces no S1 task named: admin.js (6), users.js (10), the
launcher's inline script (5). All 21 now go through wp-dialog.js - the T7.9
kit extracted as a self-injecting shared component: markup and styles land on
first use, styles are theme tokens only with its own wp-dlg-* class names (the
consoles' existing .modal styles are untouched), 44px targets on coarse
pointers, and the whole file is guarded so the creator's inline copy - which
owns the same-id markup in its HTML - still wins on its own page. The kit's
toast comes along (S10 role rules), since none of the three pages had one.

Conversion follows the T7.9 precedent: confirms -> wpConfirmDialog with named
ok-labels, the password prompt -> wpPromptDialog whose validate() finally
enforces min-12 AT the input (it was label-text-only before, server-enforced),
API failures with detail -> wpAlertDialog, small info/validation messages ->
the announced toast.

New probe console_dialogs_check (17): counts pinned at 0, kit guarded and
loaded by all three pages, and the users console driven live with natives
poisoned - reset a password end to end (short refused inline, good one accepted
by the server and announced), cancel a delete and prove nothing died.

Items: BL-024 (closed), S1 completed to zero app-wide, C1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:24:09 -07:00
b54034db04 T4.2 - S3: the app's state has an address; X1 is unblocked
Wave 0 counted pushState across html/ and found 0. Every page read its query
string once at boot and never wrote one again, so you could not send anyone a
link to WP07, a refresh dropped you back at the default view, and Back left the
app entirely because the app had never added a history entry.

CR-011 and CR-014 both promise an email carrying a direct link to a work package.
That is X1, and it was blocked on this. It is not blocked now.

html/wp-url.js is the whole mechanism, and it is deliberately NOT a router.
Nothing in it intercepts navigation or renders anything; it is the query string
treated as state that can be read, merged, written and subscribed to. Pages keep
their own rendering. Query parameters rather than a hash, because the server
already serves these paths and a hash is never sent to the server - which matters
the day a link has to be resolved before the page boots.

The merge behaviour is the part that earns its place: WPUrl.push({wp:id}) keeps
the active project, and WPUrl.push({wp:''}) clears one key without needing to know
what else is in the URL. Hand-built URLs losing ?project= is the usual way this
goes wrong.

WIRED: the creator (open package, dashboard view), the SOP wizard (tool, step),
the launcher (project). Each records a history entry only when the user chose the
change - restoring from the URL uses replace, or Back would immediately add an
entry and appear to do nothing.

WPUrl.absolute() is what CR-011/CR-014 will paste into an email in wave 8.

TWO BUGS THIS TASK CREATED AND FIXED, both found by the probe rather than by
reading:

  - bootSOP() calls newPackage() during boot, and newPackage() cleared ?wp=. A
    deep link therefore worked and then erased its own parameter, leaving Back
    with nothing to return to. Now guarded on wpCreatorReady.
  - goToStep() runs validateStep(), which ends in alert() when a required field
    is empty - always true on a freshly loaded page. So restoring ?step=3 from a
    shared link opened a modal dialog mid-boot, and hung the browser under CDP.
    Restoring a view is not a forward navigation and no longer runs the
    forward-navigation guard.

The second one is worth keeping in mind for the rest of wave 4: this app has 79
native dialogs, and any of them firing during a restore path will hang a headless
browser rather than fail visibly.

VERIFICATION. tests/url_state_check.py, 23 checks, all passing, covering every
done-when on the task:

  - a URL identifying a work package opens that package
  - the same URL for a SIGNED-OUT user goes to login, carries the target through
    ?next=, and lands on the work package itself after signing in
  - refresh preserves project, package, tab and view
  - Back and Forward move through states, verified as still-initialised rather
    than reloaded, and with the dashboard actually rendered rather than only the
    URL changed
  - a different user opening the same URL reaches the same view
  - nothing credential-shaped appears in the query string

Metric 8, pushState: was 0 at wave 0, now 2 in html/ (one pushState and one
replaceState, both in wp-url.js) behind 6 call sites across 4 files. The raw
count stays low by design - one place writes history, which is the same reason
the token work put one place in charge of colour.

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES, aggregates 16/16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:32:31 -05:00
5d5511a458 T1.1 - F1: one source of truth for the active project; the app bar subscribes
The hero, the picker and the create-user card showed the active project while
the app bar still read "Select a project". Three separate causes, all of them
the same shape - a reader with its own copy of the value.

1. Nothing told the bar. wp-chrome.js rendered projectLabel() once at build
   time and refreshed it only when /api/projects came back, so selecting a
   project updated the hero and left the bar behind. ProjectData.setActive now
   notifies, and the bar subscribes through ProjectData.onActiveChange instead
   of holding a copy. A plain array of callbacks - this is one value with a
   handful of readers, not a reason for a state library.

2. admin.html and users.html load wp-chrome.js but never loaded
   project-data.js, so window.ProjectData was undefined and their bar could
   NEVER show a project - it read "Select a project" permanently, whatever was
   selected. Both now load it, ahead of wp-chrome.js.

3. setActive({id}) erased the name. field.js, wp-creation-app.js and
   work-package-suite-app.js all set the id first and the full record second;
   writing that stub verbatim left the bar rendering "(unnamed)". setActive now
   merges onto the stored record when the id matches, so a partial write cannot
   lose fields it did not mean to touch.

Also: index.html never honoured ?project=<id>, though every other page does, so
a deep link on a browser with nothing stored showed "Select a project" while
the URL said otherwise. It now resolves the parameter before reconciling.

setActive is the only code path that writes wp_active_project /
wp_active_project_obj - project-data.js:83-105, noted there in a comment so it
stays that way. A storage listener keeps a second tab from showing a project
the user has since switched away from.

Verified, all at 1440px and against the wave 0 baseline:
  - bar shows the project on launcher, SOP wizard, admin, field, users
  - survives a hard refresh on each of them
  - selecting a project updates hero and bar in one interaction, no reload
  - with nothing selected the bar reads "Select a project" and both the
    launcher picker and the bar's own switcher are reachable
  - deep link ?project= works on a cold browser, hero and bar agree
  - setActive({id}) after a full record keeps the name

The creator is the one page with no app bar to fix: it loads neither
wp-chrome.js nor wp-chrome.css, because it renders as the iframe child of the
SOP wizard. Giving it chrome is T7.1's work once B7 dissolves that boundary -
adding it here would put a second app bar inside the embedded view. This is the
"all 6 pages" wording in the plan meeting the 7 pages that exist; see file-map
D1.

tests/f_items.py F1 now reports FIXED. F2-F6 still reproduce, untouched.
browser_check.py 71/71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:13:32 -05:00
4ace2afb1c Move user administration to its own page; add Project Super User
User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.

server/auth.py, server/app.py
  New permissions role `project_super_user`, between admin and project_admin:
  everything a project admin may do, plus user administration SCOPED to the
  projects they hold the role on. Four limits make it safe to hand out, all
  enforced server-side:

    * Scope comes from projects, not the job title. It resolves per membership
      (managed_project_ids), so an ordinary account can hold it on one job via
      ProjectMember.role, and a super user demoted on one job administers
      nobody there. No projects, no authority.
    * Account-level changes (password, disable, rename, permissions, delete)
      require EXCLUSIVE scope: refused when the target is also on a project the
      caller does not administer, because those changes are global. The
      directory renders such rows read-only with the reason.
    * No admin or super-user targets, and neither role can be granted by a
      super user -- that is the line that stops it becoming app-wide control.
    * PUT .../projects rebuilds only the caller's own slice; memberships on
      projects they do not administer are left untouched. A payload that simply
      omits them must not cut someone off a job the caller cannot see.

  Creating requires naming at least one of your own projects: an account with
  none would be one the creator instantly cannot manage.

  /api/auth/users is now scoped rather than admin-only, and carries a per-row
  `manageable` verdict plus the reason. Non-managers get a contact card only --
  a project user has no business reading colleagues' login history. New
  /api/auth/user-scope tells the page what it may offer. Administrative
  password resets are now audited; they were the one account change that left
  no trace. Settings, feature flags and the auto-add rule stay admin-only.

  While here: one definition of "is a user manager", derived from the managed
  set. An account-role-only version disagreed with the scoped one and locked
  per-project super users out of routes they were entitled to.

html/users.html, html/users.js
  The directory: three renderings from one page -- admin (everything), super
  user (controls per row, read-only where scope is shared), everyone else (a
  read-only directory of the people on their own projects).

html/console.css, html/console-util.js
  Extracted from admin.html/admin.js so both console pages share them. A
  divergent jsq() is an XSS and a divergent role list offers permissions the
  server refuses, so neither may exist twice.

html/wp-sidenav.{js,css}
  Global nav drawer, role-gated, carrying ?project= across links. Mounted on
  the field view (which had no way to anywhere) plus both console pages.

No migration: users.role is already String(20) and the new value fits.

Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.

server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:36:14 -07:00