spiderly 19.12.1 → 19.12.3

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.
@@ -10,7 +10,7 @@ import { Filter } from '../../entities/filter';
10
10
  import { LazyLoadSelectedIdsResult } from '../../entities/lazy-load-selected-ids-result';
11
11
  import { PaginatedResult } from '../../entities/paginated-result';
12
12
  import { PrimengOption } from '../../entities/primeng-option';
13
- import { FilterSource, SortKeyLabel } from '../../filters/filter-store';
13
+ import { FilterSource, SortKeyLabel, SortPickOption } from '../../filters/filter-store';
14
14
  import { SpiderlyFilterTemplateDirective } from '../../directives/spiderly-filter-template.directive';
15
15
  import { ConfigServiceBase } from '../../services/config.service.base';
16
16
  import { SpiderlyMessageService } from '../../services/spiderly-message.service';
@@ -416,23 +416,32 @@ export declare class SpiderlyDataTableComponent implements OnInit, OnChanges, Af
416
416
  private get viewSortStateKey();
417
417
  /** The operator's stored sort for the active view, sanitized like any persisted sort. */
418
418
  private persistedViewSort;
419
- /** The sort this view asks for: the operator's stored override, else its declared sort. */
420
- private viewSort;
419
+ /**
420
+ * THE resolution ladder, named once: the operator's stored override, else the declared
421
+ * default (`defaultMultiSortMeta` = view's declared sort ?? table default). Null means
422
+ * "unsorted" — callers append their own `?? []` / blob terms rather than re-spelling the
423
+ * ladder, so a future term (a per-view default refinement, decision 10's columns half)
424
+ * lands everywhere at once.
425
+ */
426
+ private resolvedViewSort;
421
427
  /**
422
428
  * Records the current sort as the active view's override — called from the EXPLICIT sort
423
- * gestures only (header click, menu sort, Clear filters), never from a load: a sort merely
424
- * inherited at init must not crystallize into an override, or a later change to the view's
425
- * declared sort would stop flowing to operators who never touched sort — the same
426
- * flows-through rule `columnVisibilityOverrides` documents. A sort equal to the declared
427
- * default is stored as ABSENCE for the same reason.
429
+ * gestures only (header click, `applySort` — the menu and both bar entry points), never
430
+ * from a load: a sort merely inherited at init must not crystallize into an override, or a
431
+ * later change to the view's declared sort would stop flowing to operators who never
432
+ * touched sort — the same flows-through rule `columnVisibilityOverrides` documents. A sort
433
+ * equal to the declared default is stored as ABSENCE for the same reason.
428
434
  */
429
435
  private persistActiveViewSort;
430
436
  /**
431
- * Puts the entering view's sort on the grid — stored override first, then the declared sort;
432
- * a view stating no preference keeps the ordering already there (the pre-decision-10-sort
433
- * behavior, and the least surprising for the tab an operator never sorted). Broadcast only —
434
- * the caller owns the refetch, because which request carries the new sort differs between the
435
- * store and storeless paths.
437
+ * Puts the entering view's sort on the grid — stored override, else declared sort, else the
438
+ * table default, else UNSORTED. A view is a COMPLETE state, sort included (2026-09-06,
439
+ * grilled — supersedes the "a view stating no preference keeps the ordering already there"
440
+ * this first shipped with): the carried-over ordering read as a bug in its first live week,
441
+ * when "Poslate"'s statusChangedAt rode onto "Sve", whose sorted column ships hidden —
442
+ * read-only chip, no header to tri-state, no way out. Polaris IndexFilters is the model:
443
+ * sort is part of a view's saved definition. Broadcast only — the caller owns the refetch,
444
+ * because which request carries the new sort differs between the store and storeless paths.
436
445
  */
437
446
  private applyViewSort;
438
447
  /**
@@ -538,8 +547,8 @@ export declare class SpiderlyDataTableComponent implements OnInit, OnChanges, Af
538
547
  /**
539
548
  * Switches to a view. Clears first, always: a view is a STATE rather than an addition, and two
540
549
  * of them composed produce a question nobody asked. The clear runs through the store directly
541
- * rather than through `clear(table)`, which would also wipe the sort and the persisted state a
542
- * view is entitled to keep.
550
+ * rather than through `clear()`, whose requery effect would spend a fetch before the entering
551
+ * view's filters are in place.
543
552
  */
544
553
  selectView(view: TableView): void;
545
554
  /**
@@ -636,6 +645,35 @@ export declare class SpiderlyDataTableComponent implements OnInit, OnChanges, Af
636
645
  * left falls back to its own name rather than vanishing.
637
646
  */
638
647
  private refreshSortKeys;
648
+ /**
649
+ * Gates the picker's reset row. Refreshed with `sortKeys` — same request, same reason it is a
650
+ * field rather than a getter. "Default" is view-aware (`defaultMultiSortMeta`), so inside a
651
+ * view the row offers the view's ordering back, not the table's.
652
+ */
653
+ sortIsDefault: boolean;
654
+ /**
655
+ * What the bar's picker offers: every sortable data column, hidden ones INCLUDED — the picker
656
+ * exists so an ordering does not need its column on screen. Built once in `ngOnInit` (columns
657
+ * are a declaration, not state) and labelled here because the bar knows nothing about columns.
658
+ */
659
+ sortPickOptions: SortPickOption[];
660
+ /**
661
+ * The one body of an explicit sort GESTURE — the thing `persistActiveViewSort`'s doc calls
662
+ * "explicit gestures only": page one, meta on the grid, fetch, save, selection anchor
663
+ * invalidated, override recorded. Shared by the header's single-click un-sort, the header
664
+ * menu and both bar entry points; `broadcastSort` is its deliberate no-fetch sibling. The
665
+ * page reset, `saveState` and anchor line mirror PrimeNG's own `sort()` (`sortMultiple()`
666
+ * does none of the three); the "lands on page one" / "survives a reload" specs pin why.
667
+ */
668
+ private applySort;
669
+ /** The picker's ask: one sort, replacing the whole meta — the same contract as the header menu. */
670
+ sortFromBar(pick: SortMeta): void;
671
+ /**
672
+ * Back to the resolved default — the view's declared sort, else the table default, else
673
+ * unsorted. Persisting right after is what empties the override slot: a sort equal to the
674
+ * default is stored as absence, so the declared ordering flows through again.
675
+ */
676
+ resetSortFromBar(): void;
639
677
  /** This column's share, override first, then its declared width, then the per-type default. */
640
678
  columnShare(col: Column): number;
641
679
  /**
@@ -716,11 +754,16 @@ export declare class SpiderlyDataTableComponent implements OnInit, OnChanges, Af
716
754
  actionTrackByFn(index: any, item: Action): string;
717
755
  exportListToExcel(): void;
718
756
  /**
719
- * Clears everything that narrows the grid. With a store supplied it has to reach BOTH — the
720
- * store holds the constraints and PrimeNG holds the persisted state and sort — or the bar goes
721
- * empty while a reload brings the old filters back.
757
+ * Clears the FILTERS — and nothing else (2026-09-06, grilled). It used to also reach the
758
+ * sort and PrimeNG's persisted state, which made "Clear filters" a reset the label never
759
+ * claimed: sort is its own channel with its own affordances (the bar's picker, the header's
760
+ * tri-state), mirroring Polaris, where onClearAll is wired to the Filters while
761
+ * sortSelected never passes through it. The store's requery effect owns the refetch and the
762
+ * persisted-snapshot update — the button only renders with chips on the bar, so the applied
763
+ * set always changes — and dropping table.clear() also drops the second, unsequenced
764
+ * request the 2026-09-05 review flagged on every Clear.
722
765
  */
723
- clear(table: Table): void;
766
+ clear(): void;
724
767
  setFakeIsAllSelected(): void;
725
768
  selectAll(checked: boolean): void;
726
769
  /**
@@ -164,12 +164,22 @@ export type FilterSnapshot = Record<string, {
164
164
  value: unknown;
165
165
  }>;
166
166
  /**
167
- * One sort key as the bar draws it. Sort deliberately does NOT live in the filter store:
168
- * `multiSortMeta` already holds it, keyed by field, and a copy here would be a second source of
169
- * truth for one fact. The bar is one surface reading two owners.
167
+ * One column the bar's sort picker offers, labelled by whoever owns the columns — the bar knows
168
+ * nothing about them. Hidden sortable columns belong in the list: the picker exists precisely so
169
+ * an ordering does not need its column on screen (decision 2b's sort half).
170
170
  */
171
- export interface SortKeyLabel {
171
+ export interface SortPickOption {
172
+ field: string;
172
173
  label: string;
174
+ }
175
+ /**
176
+ * One sort key as the bar draws it — a pick option WITH its direction. Sort deliberately does
177
+ * NOT live in the filter store: `multiSortMeta` already holds it, keyed by field, and a copy
178
+ * here would be a second source of truth for one fact. The bar is one surface reading two
179
+ * owners. `field` is what lets the picker mark the active row and flip its direction; the
180
+ * label alone could not say which option it names once two columns share a name.
181
+ */
182
+ export interface SortKeyLabel extends SortPickOption {
173
183
  descending: boolean;
174
184
  }
175
185
  /** One applied constraint, in the shape the chip bar draws. */
@@ -1,7 +1,8 @@
1
1
  import { QueryList, TemplateRef } from '@angular/core';
2
+ import { SortMeta } from 'primeng/api';
2
3
  import { SpiderlyFilterTemplateDirective } from '../directives/spiderly-filter-template.directive';
3
4
  import { MatchModeCodes } from '../enums/match-mode-enum-codes';
4
- import { AppliedFilter, FilterBarSource, FilterHandle, FilterOption, SortKeyLabel } from './filter-store';
5
+ import { AppliedFilter, FilterBarSource, FilterHandle, FilterOption, SortKeyLabel, SortPickOption } from './filter-store';
5
6
  import * as i0 from "@angular/core";
6
7
  /**
7
8
  * Lowercased and stripped of diacritics for matching. NFD decomposition handles č/ć/š/ž, but NOT
@@ -33,8 +34,20 @@ export declare class SpiderlyFilterBarComponent {
33
34
  * about columns, so whoever owns the sort resolves the names.
34
35
  */
35
36
  readonly sort: import("@angular/core").InputSignal<SortKeyLabel[]>;
37
+ /**
38
+ * What the picker offers, labelled by the owner — every sortable column, HIDDEN ones
39
+ * included (their ordering is otherwise unreachable, which is the trap the picker closes).
40
+ * Empty keeps the chip read-only and the picker off.
41
+ */
42
+ readonly sortOptions: import("@angular/core").InputSignal<SortPickOption[]>;
43
+ /** Whether the grid already sits on its default ordering — gates the reset row, nothing else. */
44
+ readonly sortIsDefault: import("@angular/core").InputSignal<boolean>;
36
45
  /** Asked for, never done here — see the button's comment. */
37
46
  readonly clearAll: import("@angular/core").OutputEmitterRef<void>;
47
+ /** A single-sort ask. Multi-sort stays a header gesture (ctrl-click); the picker replaces. */
48
+ readonly sortPick: import("@angular/core").OutputEmitterRef<SortMeta>;
49
+ /** Back to the owner's default ordering — the owner knows what that resolves to, not the bar. */
50
+ readonly sortReset: import("@angular/core").OutputEmitterRef<void>;
38
51
  private readonly transloco;
39
52
  /** For the chip's date wording — the same locale mechanism the table's cells format with. */
40
53
  private readonly locale;
@@ -45,6 +58,7 @@ export declare class SpiderlyFilterBarComponent {
45
58
  */
46
59
  readonly controlTemplates: import("@angular/core").InputSignal<QueryList<SpiderlyFilterTemplateDirective>>;
47
60
  readonly isAddOpen: import("@angular/core").WritableSignal<boolean>;
61
+ readonly isSortOpen: import("@angular/core").WritableSignal<boolean>;
48
62
  /** Cleared when the popover closes, so reopening never starts inside someone's old query. */
49
63
  readonly addSearch: import("@angular/core").WritableSignal<string>;
50
64
  /** The filter whose control is open. One at a time: the bar edits, it is not a form. */
@@ -81,6 +95,23 @@ export declare class SpiderlyFilterBarComponent {
81
95
  }[]>;
82
96
  /** Opens a filter's control. Where it renders, and why not a popover: the template comment. */
83
97
  startEditing(id: string): void;
98
+ /**
99
+ * The menu's rows: each option decorated with its ACTIVE direction's arrow (any key, not
100
+ * just the primary), or null. A `computed`, not a per-row method call in the binding — the
101
+ * popover's content is projected, so it change-detects even while closed, and a `find` per
102
+ * option per pass is exactly what `operatorChoices` documents against.
103
+ */
104
+ readonly sortRows: import("@angular/core").Signal<{
105
+ arrow: string;
106
+ field: string;
107
+ label: string;
108
+ }[]>;
109
+ /**
110
+ * Ascending first; picking the already-ascending primary flips it. The direction is decided
111
+ * HERE so the owner applies exactly what the menu showed — an owner guessing "toggle" from its
112
+ * own state could disagree with a stale menu after a concurrent header click.
113
+ */
114
+ pickSort(option: SortPickOption): void;
84
115
  /** The raw-string controls only. Nothing reaches the bar or the query until `apply`. */
85
116
  draft(handle: FilterHandle, event: Event): void;
86
117
  controlTemplateFor(handle: FilterHandle): TemplateRef<unknown> | null;
@@ -128,5 +159,5 @@ export declare class SpiderlyFilterBarComponent {
128
159
  */
129
160
  chipValue(chip: AppliedFilter): string;
130
161
  static ɵfac: i0.ɵɵFactoryDeclaration<SpiderlyFilterBarComponent, never>;
131
- static ɵcmp: i0.ɵɵComponentDeclaration<SpiderlyFilterBarComponent, "spiderly-filter-bar", never, { "filters": { "alias": "filters"; "required": true; "isSignal": true; }; "totalRecords": { "alias": "totalRecords"; "required": false; "isSignal": true; }; "sort": { "alias": "sort"; "required": false; "isSignal": true; }; "controlTemplates": { "alias": "controlTemplates"; "required": false; "isSignal": true; }; }, { "clearAll": "clearAll"; }, never, never, true, never>;
162
+ static ɵcmp: i0.ɵɵComponentDeclaration<SpiderlyFilterBarComponent, "spiderly-filter-bar", never, { "filters": { "alias": "filters"; "required": true; "isSignal": true; }; "totalRecords": { "alias": "totalRecords"; "required": false; "isSignal": true; }; "sort": { "alias": "sort"; "required": false; "isSignal": true; }; "sortOptions": { "alias": "sortOptions"; "required": false; "isSignal": true; }; "sortIsDefault": { "alias": "sortIsDefault"; "required": false; "isSignal": true; }; "controlTemplates": { "alias": "controlTemplates"; "required": false; "isSignal": true; }; }, { "clearAll": "clearAll"; "sortPick": "sortPick"; "sortReset": "sortReset"; }, never, never, true, never>;
132
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spiderly",
3
- "version": "19.12.1",
3
+ "version": "19.12.3",
4
4
  "author": "Filip Trivan",
5
5
  "license": "MIT",
6
6
  "description": "Spiderly Angular Library to use in combination with Spiderly.",
@@ -147,12 +147,49 @@
147
147
  font-size: 0.875rem;
148
148
  }
149
149
 
150
+ // The picker's trigger — same chip, now a button. bare-button resets the UA chrome; the hover
151
+ // underline mirrors the filter chips' edit affordance so "this opens something" reads the same
152
+ // across the bar.
153
+ button.sort-chip {
154
+ @include bare-button();
155
+ cursor: pointer;
156
+
157
+ &:hover .sort-chip-key,
158
+ &:hover .sort-chip-label {
159
+ text-decoration: underline;
160
+ }
161
+ }
162
+
163
+ .sort-chip-caret {
164
+ font-size: 0.65rem;
165
+ }
166
+
150
167
  .sort-chip-key {
151
168
  // The direction arrow is the non-colour channel; the priority is the reading order, which is
152
169
  // the thing the scattered header icons could never show.
153
170
  color: var(--p-text-color);
154
171
  }
155
172
 
173
+ // Teleported with its popover into document.body, so TOP LEVEL like the add menu above it
174
+ // (Angular/CLAUDE.md → overlay styling); pinned by the "sort menu styles survive the body
175
+ // teleport" spec. Inherits .filter-add-option for the row look.
176
+ .sort-menu-option {
177
+ display: flex;
178
+ justify-content: space-between;
179
+ align-items: center;
180
+ gap: 12px;
181
+ }
182
+
183
+ .sort-menu-direction {
184
+ color: var(--p-primary-color);
185
+ }
186
+
187
+ .sort-menu-reset {
188
+ border-top: 1px solid var(--p-content-border-color);
189
+ border-radius: 0;
190
+ color: var(--p-text-muted-color);
191
+ }
192
+
156
193
  .filter-bar-clear {
157
194
  // Pushed to the far end, away from the chips: a destructive action next to the x it resembles
158
195
  // is how a single chip gets removed by someone reaching for all of them. It is also the LAST
@@ -1,166 +0,0 @@
1
- ---
2
- name: verify-ui
3
- description: Log past Spiderly's email-code auth wall to visually verify the running Angular admin panel — screenshot a page, click through a flow, or dogfood a UI change without writing a test. Use when asked to "verify the admin UI", "check this page renders", "screenshot the admin panel", "does this screen look right", or to eyeball a change in the live app. For authoring Playwright test suites or debugging CI traces, use the e2e-testing skill instead.
4
- allowed-tools: Bash(node:*), Bash(npx:*), Bash(agent-browser:*), Bash(curl:*)
5
- ---
6
-
7
- # Verify Spiderly Admin UI (past the auth wall)
8
-
9
- The Spiderly admin panel sits entirely behind a passwordless **email-code** login, which blocks any agent that just opens `localhost:4200`. This skill gets you a logged-in session headlessly so you can look at the real UI.
10
-
11
- **This is for ad-hoc visual verification, not test authoring.** If you're writing a Playwright suite or debugging a failing CI run, use the `e2e-testing` skill — it owns the canonical login helper and the PrimeNG selector quirks.
12
-
13
- ## Two auth flows — check which one first
14
-
15
- - **Header flow** (`Login` + `RefreshTokenWithHeaders`): tokens in localStorage → recipe below.
16
- - **Cookie flow** (`LoginWithCookies` + `RefreshTokenWithCookies`): the refresh token is an HttpOnly cookie — localStorage injection can't work. Symptom: bearer fetches return 200, yet every load bounces to `/login` with a 401 on `RefreshTokenWithCookies`. Log in **from inside the browser** with `credentials: 'include'` instead:
17
-
18
- ```js
19
- // eval on the admin origin; email must have the Admin role
20
- const browserId = 'verify-ui';
21
- localStorage.setItem('browser_id', browserId); // must match the login below
22
- const { verificationCode } = await (await fetch(API + '/api/Security/SendLoginVerificationEmail', {
23
- method: 'POST', headers: {'Content-Type':'application/json'}, credentials: 'include',
24
- body: JSON.stringify({ email, browserId }) })).json();
25
- await fetch(API + '/api/Security/LoginWithCookies', { method: 'POST',
26
- headers: {'Content-Type':'application/json'}, credentials: 'include',
27
- body: JSON.stringify({ verificationCode, email, browserId }) });
28
- // reload — boot's RefreshTokenWithCookies now finds the cookie
29
- ```
30
-
31
- ## The core idea — header flow (driver-agnostic)
32
-
33
- In development, `SecurityService.SendLoginVerificationEmail` returns the verification code **in the response body** (when `ShouldShowVerificationCodeInNotification()` is true — i.e. `IWebHostEnvironment.IsDevelopment()` **and** SMTP is not fully configured, so `emailingService.IsConfigured()` is false). So no email inbox is needed. The flow is always:
34
-
35
- 1. `POST /api/Security/SendLoginVerificationEmail` `{ email, browserId }` → read `verificationCode`.
36
- 2. `POST /api/Security/Login` `{ verificationCode, email, browserId }` → get `accessToken` + `refreshToken`.
37
- 3. In the browser, set three `localStorage` keys on the admin origin, then reload:
38
- - `access_token`, `refresh_token`, `browser_id` (the last must equal the `browserId` used above).
39
- 4. Wait for `sidebar-menu` to appear — that confirms you're authenticated and in.
40
-
41
- Step 1+2 are scripted for you: **`scripts/get-admin-token.mjs`** (pure Node, no dependencies, fails loudly).
42
-
43
- ```bash
44
- node scripts/get-admin-token.mjs --email admin@example.com
45
- # → { "accessToken": "...", "refreshToken": "...", "browserId": "verify-ui" }
46
- ```
47
-
48
- Flags: `--email` (required — must have the **Admin role**, see gotcha below), `--api` (default `http://localhost:5000`), `--browser-id` (default `verify-ui`).
49
-
50
- > **Don't trust the default ports — read them from the project.** The `:5000` (backend) and `:4200` (admin) values used throughout this skill are only the Spiderly scaffold defaults; any project can change them. The authoritative values live in **`Frontend/src/environments/environment.ts`**:
51
- > - `apiUrl` (e.g. `http://localhost:5000/api`) → pass its **origin** (strip the trailing `/api`) to `--api`.
52
- > - `frontendUrl` (e.g. `http://localhost:4200`) → the admin URL to open in the browser.
53
- >
54
- > The backend's actually-bound port is in **`Backend/<App>.WebAPI/Properties/launchSettings.json`** → `applicationUrl`. If a connection fails or the project looks non-standard, read those files first instead of assuming the defaults below.
55
-
56
- > **CORS origin must match.** The backend allows one origin (`appsettings.json` → `Spiderly.Shared:FrontendUrl`). Serving the admin on a different port blocks every API call and surfaces as a misleading **"Connection Lost"** toast + bounce to `/login` (looks like an auth failure).
57
-
58
- ## Prerequisites — validate before doing anything
59
-
60
- The token script already fails loudly on the first two. Confirm all four up front so you don't burn a turn on a half-running stack:
61
-
62
- | Requirement | How to confirm | If it fails |
63
- |---|---|---|
64
- | Backend reachable | the script's first POST succeeds (port from `launchSettings.json` → `applicationUrl`) | start the backend (`dotnet run` in the WebAPI project) |
65
- | Dev-mode code exposure on | response contains `verificationCode` | run backend with `ASPNETCORE_ENVIRONMENT=Development` **and** SMTP unconfigured (`IsConfigured()` false) — a fully-configured SMTP setup does a real send even in Development |
66
- | Admin SPA running | `frontendUrl` (from `environment.ts`, default `:4200`) responds | start the admin (`npm start` in `Frontend/`) |
67
- | Account has Admin role | login lands on a populated sidebar, not an empty shell | use an Admin-roled email (see gotcha) |
68
-
69
- ## Driver A — agent-browser (preferred when available)
70
-
71
- [agent-browser](https://www.skills.sh/vercel-labs/agent-browser/agent-browser) gives an agent live, interactive control — ideal for clicking through a flow and screenshotting. If it isn't installed, install once (or skip to Driver B):
72
-
73
- ```bash
74
- npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser
75
- npm i -g agent-browser && agent-browser install # CLI + Chrome binary
76
- ```
77
-
78
- Then log in and verify (replace `<AT>` / `<RT>` with the script's output, and `verify-ui` if you changed `--browser-id`):
79
-
80
- ```bash
81
- # 1. Open the admin origin first so localStorage writes land on the right origin
82
- agent-browser open http://localhost:4200
83
-
84
- # 2. Inject the session (use --stdin so quoting can't corrupt the values)
85
- agent-browser eval --stdin <<'EOF'
86
- localStorage.setItem('access_token', '<AT>');
87
- localStorage.setItem('refresh_token', '<RT>');
88
- localStorage.setItem('browser_id', 'verify-ui');
89
- EOF
90
-
91
- # 3. Navigate straight to the page under review — this load reads the just-set
92
- # localStorage; the sidebar-menu wait confirms auth (it renders on every
93
- # authenticated route, so no separate dashboard round-trip is needed).
94
- agent-browser open http://localhost:4200/administration/banner \
95
- && agent-browser wait "sidebar-menu" \
96
- && agent-browser wait --load networkidle \
97
- && agent-browser screenshot --full
98
- ```
99
-
100
- From here use the normal agent-browser loop (`snapshot -i` → `click`/`fill` → re-snapshot) to drive a flow. Close the session when done: `agent-browser close`.
101
-
102
- ## Driver B — Playwright (guaranteed floor, zero extra install)
103
-
104
- Every Spiderly app ships Playwright for e2e, so this always works without installing anything new. Run it **from the `Frontend/` directory** so `playwright` resolves. It reuses the same token script.
105
-
106
- Save as `Frontend/verify-page.mjs` (gitignore it — it's a throwaway), then:
107
-
108
- ```bash
109
- node verify-page.mjs --email admin@example.com \
110
- --url http://localhost:4200/administration/banner \
111
- --token-script <skill-dir>/scripts/get-admin-token.mjs --out ./_verify.png
112
- ```
113
-
114
- It reuses the same token helper (`--token-script`, required — pass the absolute path to this skill's `scripts/get-admin-token.mjs`), so the localStorage key names and `sidebar-menu` wait stay defined in one place. Keep that injection block in sync with `e2e-testing`'s `authenticateBrowser` if either changes.
115
-
116
- ```js
117
- import { chromium } from "playwright";
118
- import { execFileSync } from "node:child_process";
119
-
120
- function arg(name, fallback) {
121
- const i = process.argv.indexOf(`--${name}`);
122
- return i !== -1 ? process.argv[i + 1] : fallback;
123
- }
124
-
125
- const email = arg("email");
126
- const url = arg("url", "http://localhost:4200");
127
- const api = arg("api", "http://localhost:5000");
128
- const out = arg("out", "./_verify.png");
129
- const tokenScript = arg("token-script");
130
- if (!email || !tokenScript) {
131
- throw new Error("Pass --email <admin-roled account> and --token-script <path to get-admin-token.mjs>");
132
- }
133
-
134
- const { accessToken, refreshToken, browserId } = JSON.parse(
135
- execFileSync("node", [tokenScript, "--email", email, "--api", api], { encoding: "utf8" })
136
- );
137
-
138
- const browser = await chromium.launch();
139
- try {
140
- const page = await browser.newPage();
141
- // Tokens must be set on the admin origin before the app reads them, so land there first.
142
- await page.goto(new URL(url).origin);
143
- await page.evaluate(
144
- ([at, rt, bid]) => {
145
- localStorage.setItem("access_token", at);
146
- localStorage.setItem("refresh_token", rt);
147
- localStorage.setItem("browser_id", bid);
148
- },
149
- [accessToken, refreshToken, browserId]
150
- );
151
- await page.goto(url);
152
- await page.locator("sidebar-menu").waitFor({ state: "visible", timeout: 15000 });
153
- await page.screenshot({ path: out, fullPage: true });
154
- console.log(`Saved ${out}`);
155
- } finally {
156
- await browser.close();
157
- }
158
- ```
159
-
160
- ## Gotcha — login can succeed but show an empty shell
161
-
162
- `SendLoginVerificationEmail` mints a session for any email when `OnlyAdminCanAddUsers` is `false` (the default) — a not-yet-existing user is **auto-provisioned with no role**, so the sidebar comes up empty and most pages are inaccessible. If you can log in but see nothing useful, the account lacks permissions. Use an email that already has the **Admin role** (role/permission assignment is seeded per-project, e.g. via a `PermissionSeeder`). Permissions hang off roles, not off the user directly.
163
-
164
- ## Why a separate skill from e2e-testing
165
-
166
- Same login mechanism, different job: `e2e-testing` is for *authoring* Playwright suites and debugging CI traces; this is for *looking at* the running admin UI ad-hoc. Keeping them separate keeps each skill's trigger sharp. The login flow lives in both only as a thin reference — `e2e-testing` holds the canonical Playwright helper (`authenticateBrowser`); this skill holds the dependency-free token script and the agent-browser path.
@@ -1,134 +0,0 @@
1
- #!/usr/bin/env node
2
- // Obtain a Spiderly admin session token without an email inbox.
3
- //
4
- // In development, SecurityService.SendLoginVerificationEmail returns the
5
- // verification code directly in the response body (when
6
- // ShouldShowVerificationCodeInNotification() is true). This script uses that
7
- // to complete the passwordless login flow headlessly and print the tokens an
8
- // admin SPA expects in localStorage.
9
- //
10
- // The --api default (http://localhost:5000) is the Spiderly scaffold default only.
11
- // The authoritative backend URL for a project is the origin of `apiUrl` in
12
- // Frontend/src/environments/environment.ts (strip the trailing /api); the bound
13
- // port is in Backend/<App>.WebAPI/Properties/launchSettings.json -> applicationUrl.
14
- // Pass --api explicitly when a project overrides the default.
15
- //
16
- // Usage:
17
- // node get-admin-token.mjs --email admin@example.com
18
- // node get-admin-token.mjs --email admin@example.com --api http://localhost:5000 --browser-id verify-ui
19
- //
20
- // Output (stdout, JSON): { "accessToken": "...", "refreshToken": "...", "browserId": "..." }
21
- // Exit code 0 on success, 1 on any failure (fails loudly — no partial success).
22
-
23
- function parseArgs(argv) {
24
- const args = {};
25
- for (let i = 0; i < argv.length; i++) {
26
- const token = argv[i];
27
- if (!token.startsWith("--")) continue;
28
-
29
- // Support both --flag=value and --flag value.
30
- const eq = token.indexOf("=");
31
- if (eq !== -1) {
32
- args[token.slice(2, eq)] = token.slice(eq + 1);
33
- continue;
34
- }
35
-
36
- const key = token.slice(2);
37
- const next = argv[i + 1];
38
- if (next !== undefined && !next.startsWith("--")) {
39
- args[key] = next;
40
- i++;
41
- } else {
42
- args[key] = true;
43
- }
44
- }
45
- return args;
46
- }
47
-
48
- function fail(message) {
49
- console.error(`[get-admin-token] ${message}`);
50
- process.exit(1);
51
- }
52
-
53
- const args = parseArgs(process.argv.slice(2));
54
-
55
- const email = args.email;
56
- const apiBaseUrl = (args.api || "http://localhost:5000").replace(/\/+$/, "");
57
- const browserId = args["browser-id"] || "verify-ui";
58
-
59
- if (!email || email === true) {
60
- fail(
61
- "Missing required --email. Pass an account that has the Admin role in the target DB.\n" +
62
- " Usage: node get-admin-token.mjs --email admin@example.com [--api http://localhost:5000] [--browser-id verify-ui]"
63
- );
64
- }
65
-
66
- async function postJson(path, body) {
67
- let response;
68
- try {
69
- response = await fetch(`${apiBaseUrl}${path}`, {
70
- method: "POST",
71
- headers: { "Content-Type": "application/json" },
72
- body: JSON.stringify(body),
73
- });
74
- } catch (err) {
75
- fail(
76
- `Could not reach the backend at ${apiBaseUrl}${path}. Is the backend running?\n ${err.message}`
77
- );
78
- }
79
-
80
- const text = await response.text();
81
- let json;
82
- try {
83
- json = text ? JSON.parse(text) : {};
84
- } catch {
85
- json = { raw: text };
86
- }
87
-
88
- if (!response.ok) {
89
- fail(`POST ${path} failed (HTTP ${response.status}): ${text || "(empty body)"}`);
90
- }
91
-
92
- return json;
93
- }
94
-
95
- const sendCodeResult = await postJson("/api/Security/SendLoginVerificationEmail", {
96
- email,
97
- browserId,
98
- });
99
-
100
- const verificationCode = sendCodeResult.verificationCode;
101
- if (!verificationCode) {
102
- fail(
103
- "SendLoginVerificationEmail did not return a verificationCode.\n" +
104
- " This means dev-mode code exposure is OFF (ShouldShowVerificationCodeInNotification() returned false).\n" +
105
- " The gate is IsDevelopment() && !IsConfigured(): run the backend with ASPNETCORE_ENVIRONMENT=Development\n" +
106
- " AND with SMTP left unconfigured. A fully-configured SMTP setup (EmailSender.Email + EmailSenderPassword\n" +
107
- " + SmtpHost + SmtpPort) does a real email send instead, even in Development."
108
- );
109
- }
110
-
111
- const loginResult = await postJson("/api/Security/Login", {
112
- verificationCode,
113
- email,
114
- browserId,
115
- });
116
-
117
- if (!loginResult.accessToken || !loginResult.refreshToken) {
118
- fail(
119
- `Login did not return tokens. Response: ${JSON.stringify(loginResult)}\n` +
120
- " The email may not exist, or the code expired. Check the account and retry."
121
- );
122
- }
123
-
124
- process.stdout.write(
125
- JSON.stringify(
126
- {
127
- accessToken: loginResult.accessToken,
128
- refreshToken: loginResult.refreshToken,
129
- browserId,
130
- },
131
- null,
132
- 2
133
- ) + "\n"
134
- );