@franzzemen/brokenstock-shell 0.4.7 → 0.6.0
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.
|
@@ -349,6 +349,18 @@ class AuthenticationService {
|
|
|
349
349
|
...(ngDevMode ? [{ debugName: "userSession" }] : /* istanbul ignore next */ []));
|
|
350
350
|
this.conflictSessions = signal(undefined, /* @ts-ignore */
|
|
351
351
|
...(ngDevMode ? [{ debugName: "conflictSessions" }] : /* istanbul ignore next */ []));
|
|
352
|
+
/**
|
|
353
|
+
* Whether this app lets you sign in ALONGSIDE the sessions listed in
|
|
354
|
+
* `conflictSessions` (multiple-devices D3).
|
|
355
|
+
*
|
|
356
|
+
* ⚠ READ FROM THE SERVER'S REFUSAL, NEVER DECIDED HERE. This component is
|
|
357
|
+
* shared by the trading app and both admin consoles, which have different
|
|
358
|
+
* rules. Deciding it locally would put the policy in two places, and the one
|
|
359
|
+
* a reader would find is not the one that enforces it — an admin screen would
|
|
360
|
+
* happily offer a button the server refuses.
|
|
361
|
+
*/
|
|
362
|
+
this.additionalSignInAllowed = signal(false, /* @ts-ignore */
|
|
363
|
+
...(ngDevMode ? [{ debugName: "additionalSignInAllowed" }] : /* istanbul ignore next */ []));
|
|
352
364
|
this.pendingCredentials = signal(undefined, /* @ts-ignore */
|
|
353
365
|
...(ngDevMode ? [{ debugName: "pendingCredentials" }] : /* istanbul ignore next */ []));
|
|
354
366
|
this.#refreshing = null;
|
|
@@ -439,6 +451,7 @@ class AuthenticationService {
|
|
|
439
451
|
}
|
|
440
452
|
clearConflict() {
|
|
441
453
|
this.conflictSessions.set(undefined);
|
|
454
|
+
this.additionalSignInAllowed.set(false);
|
|
442
455
|
this.pendingCredentials.set(undefined);
|
|
443
456
|
}
|
|
444
457
|
/**
|
|
@@ -477,7 +490,17 @@ class AuthenticationService {
|
|
|
477
490
|
},
|
|
478
491
|
});
|
|
479
492
|
}
|
|
480
|
-
|
|
493
|
+
/**
|
|
494
|
+
* Signs in, and — on a second attempt — says what to do about the sessions
|
|
495
|
+
* already held (multiple-devices D2/D6).
|
|
496
|
+
*
|
|
497
|
+
* `force` and `additional` are different answers, not synonyms: `force` ends
|
|
498
|
+
* every session in this app, `additional` leaves them all running.
|
|
499
|
+
* `endSessionIds` ends the ones the user picked and leaves the rest. None of
|
|
500
|
+
* the three appears on a first attempt — they exist because the server
|
|
501
|
+
* refused once and said what was live.
|
|
502
|
+
*/
|
|
503
|
+
login(credentials, force = false, options = {}) {
|
|
481
504
|
const validation = validateCredentials(credentials);
|
|
482
505
|
if (validation !== true) {
|
|
483
506
|
return Promise.reject('Invalid credentials');
|
|
@@ -486,7 +509,15 @@ class AuthenticationService {
|
|
|
486
509
|
this.#thinkingService.startThinking();
|
|
487
510
|
return new Promise((resolve, reject) => {
|
|
488
511
|
this.authenticating.set(true);
|
|
489
|
-
|
|
512
|
+
// Fields are added only when they carry a decision. The route's body
|
|
513
|
+
// schema is strict, and sending `additional: false` on a first attempt
|
|
514
|
+
// would be a statement where silence is meant.
|
|
515
|
+
const body = {
|
|
516
|
+
...credentials,
|
|
517
|
+
...(force ? { force: true } : {}),
|
|
518
|
+
...(options.additional ? { additional: true } : {}),
|
|
519
|
+
...(options.endSessionIds?.length ? { endSessionIds: options.endSessionIds } : {}),
|
|
520
|
+
};
|
|
490
521
|
const subscription = this.#httpClient
|
|
491
522
|
.post(this.#authUrls.login, body, {
|
|
492
523
|
headers: {
|
|
@@ -527,6 +558,10 @@ class AuthenticationService {
|
|
|
527
558
|
try {
|
|
528
559
|
if (err.status === 409) {
|
|
529
560
|
this.conflictSessions.set(err.error?.activeSessions ?? []);
|
|
561
|
+
// Absent means false: an older back end that does not send this
|
|
562
|
+
// does not permit signing in alongside, so the screen must not
|
|
563
|
+
// offer it.
|
|
564
|
+
this.additionalSignInAllowed.set(err.error?.additionalSignInAllowed === true);
|
|
530
565
|
this.pendingCredentials.set(credentials);
|
|
531
566
|
reject('conflict');
|
|
532
567
|
}
|
|
@@ -2558,9 +2593,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
2558
2593
|
args: [{ providedIn: 'root' }]
|
|
2559
2594
|
}] });
|
|
2560
2595
|
|
|
2596
|
+
/**
|
|
2597
|
+
* Names the shell handles itself, ahead of the registry. A command taking one
|
|
2598
|
+
* of these would be registered and then never reachable, so registration
|
|
2599
|
+
* refuses them outright rather than leaving a command that silently never runs.
|
|
2600
|
+
*/
|
|
2601
|
+
const RESERVED_NAMES = ['clear', 'help'];
|
|
2561
2602
|
class CommandRegistryService {
|
|
2562
2603
|
constructor() {
|
|
2563
2604
|
this.commands = new Map();
|
|
2605
|
+
this.aliases = new Map();
|
|
2564
2606
|
// Built-in shell commands registered ahead of app-provided commands so
|
|
2565
2607
|
// an app cannot accidentally shadow them.
|
|
2566
2608
|
this.register(inject(FaqCommand));
|
|
@@ -2569,8 +2611,47 @@ class CommandRegistryService {
|
|
|
2569
2611
|
this.register(cmd);
|
|
2570
2612
|
}
|
|
2571
2613
|
}
|
|
2614
|
+
/**
|
|
2615
|
+
* Register a command and claim its names.
|
|
2616
|
+
*
|
|
2617
|
+
* ⭐ THROWS on a name already taken — real name or alternate. It used to be a
|
|
2618
|
+
* bare `set`, which meant a clash silently overwrote whichever command
|
|
2619
|
+
* registered first, and the constructor's "built-ins first so an app cannot
|
|
2620
|
+
* shadow them" comment was not actually true of the map. A shadowed command
|
|
2621
|
+
* is indistinguishable from a broken one, and this is the map dispatch reads,
|
|
2622
|
+
* so the failure belongs at startup where it cannot be missed
|
|
2623
|
+
* (scanner-command-speed.prd.md D4).
|
|
2624
|
+
*/
|
|
2572
2625
|
register(command) {
|
|
2626
|
+
this.#claim(command.name, `command '${command.name}'`);
|
|
2573
2627
|
this.commands.set(command.name, command);
|
|
2628
|
+
for (const alias of command.aliases ?? []) {
|
|
2629
|
+
this.#claim(alias.name, `alias '${alias.name}' of '${command.name}'`);
|
|
2630
|
+
this.aliases.set(alias.name, { command, prependArgs: alias.prependArgs ?? [] });
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
#claim(name, what) {
|
|
2634
|
+
if (RESERVED_NAMES.includes(name)) {
|
|
2635
|
+
throw new Error(`${what} collides with a reserved command name`);
|
|
2636
|
+
}
|
|
2637
|
+
if (this.commands.has(name) || this.aliases.has(name)) {
|
|
2638
|
+
throw new Error(`${what} collides with a command name already registered`);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Resolve a word the user typed to the command it names — real name or
|
|
2643
|
+
* alternate. `prependArgs` is empty except for an alternate that carries a
|
|
2644
|
+
* subcommand.
|
|
2645
|
+
*
|
|
2646
|
+
* Use this wherever a TYPED word is being looked up. {@link get} deliberately
|
|
2647
|
+
* stays blind to alternates so callers that mean "the command called X" keep
|
|
2648
|
+
* meaning exactly that.
|
|
2649
|
+
*/
|
|
2650
|
+
resolve(name) {
|
|
2651
|
+
const command = this.commands.get(name);
|
|
2652
|
+
if (command)
|
|
2653
|
+
return { command, prependArgs: [] };
|
|
2654
|
+
return this.aliases.get(name);
|
|
2574
2655
|
}
|
|
2575
2656
|
get(name) {
|
|
2576
2657
|
return this.commands.get(name);
|
|
@@ -2586,11 +2667,15 @@ class CommandRegistryService {
|
|
|
2586
2667
|
if (!prefix)
|
|
2587
2668
|
return [];
|
|
2588
2669
|
if (!isAuthenticated) {
|
|
2589
|
-
const open = ['login', ...[...this.commands.values()]
|
|
2670
|
+
const open = ['login', ...[...this.commands.values()]
|
|
2671
|
+
.filter(c => c.requiresAuth === false)
|
|
2672
|
+
.flatMap(c => [c.name, ...(c.aliases ?? []).map(a => a.name)])];
|
|
2590
2673
|
return open.filter(name => name.startsWith(prefix) && name !== prefix).sort();
|
|
2591
2674
|
}
|
|
2592
2675
|
const builtIns = ['clear', 'help'];
|
|
2593
|
-
|
|
2676
|
+
// Alternate names are offered too: typing `sca` and being offered nothing
|
|
2677
|
+
// is the exact failure that produced the ref/reference pair (D5).
|
|
2678
|
+
const registered = [...this.commands.keys(), ...this.aliases.keys()];
|
|
2594
2679
|
return [...builtIns, ...registered]
|
|
2595
2680
|
.filter(name => name.startsWith(prefix) && name !== prefix)
|
|
2596
2681
|
.sort();
|
|
@@ -2678,7 +2763,10 @@ class CommandService {
|
|
|
2678
2763
|
const input = validation.data;
|
|
2679
2764
|
const parts = tokenize(input);
|
|
2680
2765
|
const command = parts[0].toLowerCase();
|
|
2681
|
-
|
|
2766
|
+
// Resolved rather than looked up by name: a command typed under an
|
|
2767
|
+
// alternate name must still get its own input sanitiser (a password line
|
|
2768
|
+
// masked under `login` stays masked under any other word for it).
|
|
2769
|
+
const def = this.registry.resolve(command)?.command;
|
|
2682
2770
|
const displayInput = def?.sanitizeInput ? def.sanitizeInput(input) : input;
|
|
2683
2771
|
const resultOrPromise = this.#dispatch(command, parts.slice(1));
|
|
2684
2772
|
this.currentInput.set('');
|
|
@@ -2770,13 +2858,30 @@ class CommandService {
|
|
|
2770
2858
|
isHelp: true,
|
|
2771
2859
|
};
|
|
2772
2860
|
}
|
|
2773
|
-
|
|
2861
|
+
/*
|
|
2862
|
+
* Resolution, not a name lookup — `scan` and `scans` reach the scanner the
|
|
2863
|
+
* same way `scanner` does. An alternate may also carry leading words
|
|
2864
|
+
* (`scans NVDA` IS `scanner select NVDA`), which are inserted ahead of what
|
|
2865
|
+
* the user typed.
|
|
2866
|
+
*
|
|
2867
|
+
* ⭐ `command` keeps the word the USER typed. Every message below
|
|
2868
|
+
* interpolates it, and telling someone who typed `scan` that the arguments
|
|
2869
|
+
* of 'scanner' are wrong reads as though something else ran. The two-word
|
|
2870
|
+
* swap is the one deliberate exception, because there the typed first word
|
|
2871
|
+
* really was an argument.
|
|
2872
|
+
*/
|
|
2873
|
+
let resolved = this.registry.resolve(command);
|
|
2874
|
+
if (resolved) {
|
|
2875
|
+
args = [...resolved.prependArgs, ...args];
|
|
2876
|
+
}
|
|
2877
|
+
let def = resolved?.command;
|
|
2774
2878
|
if (!def) {
|
|
2775
2879
|
// Try swapping the first two tokens: "disable user foo" → "user disable foo"
|
|
2776
2880
|
const swapped = args[0]?.toLowerCase();
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2881
|
+
const swappedResolved = swapped ? this.registry.resolve(swapped) : undefined;
|
|
2882
|
+
if (swappedResolved) {
|
|
2883
|
+
def = swappedResolved.command;
|
|
2884
|
+
args = [...swappedResolved.prependArgs, command, ...args.slice(1)];
|
|
2780
2885
|
command = swapped;
|
|
2781
2886
|
}
|
|
2782
2887
|
else {
|
|
@@ -2814,7 +2919,7 @@ class CommandService {
|
|
|
2814
2919
|
return { success: true, message: CLEAR_USAGE, timestamp: new Date(), isHelp: true };
|
|
2815
2920
|
if (name === 'help')
|
|
2816
2921
|
return { success: true, message: HELP_USAGE, timestamp: new Date(), isHelp: true };
|
|
2817
|
-
const def = this.registry.
|
|
2922
|
+
const def = this.registry.resolve(name)?.command;
|
|
2818
2923
|
if (!def)
|
|
2819
2924
|
return { success: false, message: `Unknown command: ${name}`, timestamp: new Date() };
|
|
2820
2925
|
// Usage is still shown for a gated command — knowing how it works is how a
|
|
@@ -2970,11 +3075,11 @@ class MainContent {
|
|
|
2970
3075
|
this.#shortcutService.helpVisible.set(false);
|
|
2971
3076
|
}
|
|
2972
3077
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: MainContent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2973
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: MainContent, isStandalone: true, selector: "shell-main-content", ngImport: i0, template: "<div class=\"main-content\">\n <div class=\"content-area\">\n <router-outlet />\n </div>\n @if (shortcutHelpVisible() && activeShortcuts()) {\n <div class=\"shortcut-overlay\" (click)=\"closeShortcutHelp()\">\n <div class=\"shortcut-panel\" (click)=\"$event.stopPropagation()\">\n <div class=\"shortcut-title text-secondary-info\">{{ activeShortcuts()!.groupId }} shortcuts</div>\n @for (s of activeShortcuts()!.shortcuts; track s.key) {\n <div class=\"shortcut-row\">\n <kbd class=\"shortcut-key\">Alt+{{ formatKey(s.key) }}</kbd>\n <span class=\"shortcut-desc text-text-primary\">{{ s.description }}</span>\n </div>\n }\n <div class=\"shortcut-row shortcut-dismiss\">\n <kbd class=\"shortcut-key\">Alt+?</kbd>\n <span class=\"shortcut-desc text-secondary-info\">or</span>\n <kbd class=\"shortcut-key\">Esc</kbd>\n <span class=\"shortcut-desc text-secondary-info\">dismiss</span>\n </div>\n </div>\n </div>\n }\n @if (isThinking()) {\n <div class=\"thinking-overlay\">\n <mat-spinner diameter=\"48\" />\n </div>\n }\n</div>\n", styles: [".main-content{position:relative;height:100%;overflow:hidden}.content-area{height:100%;overflow-y:auto;padding:16px}.shortcut-overlay{position:absolute;inset:0;z-index:15;display:flex;align-items:center;justify-content:center;background-color:#0a0e1480}.shortcut-panel{background-color:var(--color-terminal-bg);border:1px solid var(--color-surface-border);border-radius:6px;padding:14px 18px;font-size:.8rem;box-shadow:0 4px 16px #00000080;min-width:220px}.shortcut-title{font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px;font-weight:500}.shortcut-row{display:flex;align-items:center;gap:10px;padding:3px 0}.shortcut-row.shortcut-dismiss{margin-top:6px;border-top:1px solid var(--color-surface-border);padding-top:8px}.shortcut-key{font-family:Roboto Mono,monospace;font-size:.75rem;background-color:#ffffff14;border:1px solid var(--color-surface-border);border-radius:3px;padding:1px 6px;min-width:70px;text-align:center}.shortcut-desc{font-size:.78rem}.thinking-overlay{position:absolute;inset:0;z-index:20;background-color:#0a0e14b3;display:flex;align-items:center;justify-content:center;pointer-events:all;cursor:wait}\n"], dependencies: [{ kind: "directive", type: RouterOutlet, selector: "router-outlet", inputs: ["name", "routerOutletData"], outputs: ["activate", "deactivate", "attach", "detach"], exportAs: ["outlet"] }, { kind: "ngmodule", type: MatProgressSpinnerModule }, { kind: "component", type: i1$1.MatProgressSpinner, selector: "mat-progress-spinner, mat-spinner", inputs: ["color", "mode", "value", "diameter", "strokeWidth"], exportAs: ["matProgressSpinner"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
3078
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: MainContent, isStandalone: true, selector: "shell-main-content", ngImport: i0, template: "<div class=\"main-content\">\n <div class=\"content-area\">\n <router-outlet />\n </div>\n @if (shortcutHelpVisible() && activeShortcuts()) {\n <div class=\"shortcut-overlay\" (click)=\"closeShortcutHelp()\">\n <div class=\"shortcut-panel\" (click)=\"$event.stopPropagation()\">\n <div class=\"shortcut-title text-secondary-info\">{{ activeShortcuts()!.groupId }} shortcuts</div>\n @for (s of activeShortcuts()!.shortcuts; track s.key) {\n <div class=\"shortcut-row\">\n <kbd class=\"shortcut-key\">Alt+{{ formatKey(s.key) }}</kbd>\n <span class=\"shortcut-desc text-text-primary\">{{ s.description }}</span>\n </div>\n }\n <!--\n Esc is documented rather than added (scanner-command-speed.prd.md D24/E4).\n It has always put the cursor in the command box, from anywhere and even\n while typing in a field \u2014 it was simply never written down, which is why\n it read as a missing feature. Worded for what it actually does: it also\n closes this panel, and it does not check whether a dialog is open.\n -->\n <div class=\"shortcut-row\">\n <kbd class=\"shortcut-key\">Esc</kbd>\n <span class=\"shortcut-desc text-text-primary\">Put the cursor in the command box, from anywhere</span>\n </div>\n <div class=\"shortcut-row shortcut-dismiss\">\n <kbd class=\"shortcut-key\">Alt+?</kbd>\n <span class=\"shortcut-desc text-secondary-info\">or</span>\n <kbd class=\"shortcut-key\">Esc</kbd>\n <span class=\"shortcut-desc text-secondary-info\">dismiss</span>\n </div>\n </div>\n </div>\n }\n @if (isThinking()) {\n <div class=\"thinking-overlay\">\n <mat-spinner diameter=\"48\" />\n </div>\n }\n</div>\n", styles: [".main-content{position:relative;height:100%;overflow:hidden}.content-area{height:100%;overflow-y:auto;padding:16px}.shortcut-overlay{position:absolute;inset:0;z-index:15;display:flex;align-items:center;justify-content:center;background-color:#0a0e1480}.shortcut-panel{background-color:var(--color-terminal-bg);border:1px solid var(--color-surface-border);border-radius:6px;padding:14px 18px;font-size:.8rem;box-shadow:0 4px 16px #00000080;min-width:220px}.shortcut-title{font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px;font-weight:500}.shortcut-row{display:flex;align-items:center;gap:10px;padding:3px 0}.shortcut-row.shortcut-dismiss{margin-top:6px;border-top:1px solid var(--color-surface-border);padding-top:8px}.shortcut-key{font-family:Roboto Mono,monospace;font-size:.75rem;background-color:#ffffff14;border:1px solid var(--color-surface-border);border-radius:3px;padding:1px 6px;min-width:70px;text-align:center}.shortcut-desc{font-size:.78rem}.thinking-overlay{position:absolute;inset:0;z-index:20;background-color:#0a0e14b3;display:flex;align-items:center;justify-content:center;pointer-events:all;cursor:wait}\n"], dependencies: [{ kind: "directive", type: RouterOutlet, selector: "router-outlet", inputs: ["name", "routerOutletData"], outputs: ["activate", "deactivate", "attach", "detach"], exportAs: ["outlet"] }, { kind: "ngmodule", type: MatProgressSpinnerModule }, { kind: "component", type: i1$1.MatProgressSpinner, selector: "mat-progress-spinner, mat-spinner", inputs: ["color", "mode", "value", "diameter", "strokeWidth"], exportAs: ["matProgressSpinner"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
2974
3079
|
}
|
|
2975
3080
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: MainContent, decorators: [{
|
|
2976
3081
|
type: Component,
|
|
2977
|
-
args: [{ selector: 'shell-main-content', imports: [RouterOutlet, MatProgressSpinnerModule], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"main-content\">\n <div class=\"content-area\">\n <router-outlet />\n </div>\n @if (shortcutHelpVisible() && activeShortcuts()) {\n <div class=\"shortcut-overlay\" (click)=\"closeShortcutHelp()\">\n <div class=\"shortcut-panel\" (click)=\"$event.stopPropagation()\">\n <div class=\"shortcut-title text-secondary-info\">{{ activeShortcuts()!.groupId }} shortcuts</div>\n @for (s of activeShortcuts()!.shortcuts; track s.key) {\n <div class=\"shortcut-row\">\n <kbd class=\"shortcut-key\">Alt+{{ formatKey(s.key) }}</kbd>\n <span class=\"shortcut-desc text-text-primary\">{{ s.description }}</span>\n </div>\n }\n <div class=\"shortcut-row shortcut-dismiss\">\n <kbd class=\"shortcut-key\">Alt+?</kbd>\n <span class=\"shortcut-desc text-secondary-info\">or</span>\n <kbd class=\"shortcut-key\">Esc</kbd>\n <span class=\"shortcut-desc text-secondary-info\">dismiss</span>\n </div>\n </div>\n </div>\n }\n @if (isThinking()) {\n <div class=\"thinking-overlay\">\n <mat-spinner diameter=\"48\" />\n </div>\n }\n</div>\n", styles: [".main-content{position:relative;height:100%;overflow:hidden}.content-area{height:100%;overflow-y:auto;padding:16px}.shortcut-overlay{position:absolute;inset:0;z-index:15;display:flex;align-items:center;justify-content:center;background-color:#0a0e1480}.shortcut-panel{background-color:var(--color-terminal-bg);border:1px solid var(--color-surface-border);border-radius:6px;padding:14px 18px;font-size:.8rem;box-shadow:0 4px 16px #00000080;min-width:220px}.shortcut-title{font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px;font-weight:500}.shortcut-row{display:flex;align-items:center;gap:10px;padding:3px 0}.shortcut-row.shortcut-dismiss{margin-top:6px;border-top:1px solid var(--color-surface-border);padding-top:8px}.shortcut-key{font-family:Roboto Mono,monospace;font-size:.75rem;background-color:#ffffff14;border:1px solid var(--color-surface-border);border-radius:3px;padding:1px 6px;min-width:70px;text-align:center}.shortcut-desc{font-size:.78rem}.thinking-overlay{position:absolute;inset:0;z-index:20;background-color:#0a0e14b3;display:flex;align-items:center;justify-content:center;pointer-events:all;cursor:wait}\n"] }]
|
|
3082
|
+
args: [{ selector: 'shell-main-content', imports: [RouterOutlet, MatProgressSpinnerModule], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"main-content\">\n <div class=\"content-area\">\n <router-outlet />\n </div>\n @if (shortcutHelpVisible() && activeShortcuts()) {\n <div class=\"shortcut-overlay\" (click)=\"closeShortcutHelp()\">\n <div class=\"shortcut-panel\" (click)=\"$event.stopPropagation()\">\n <div class=\"shortcut-title text-secondary-info\">{{ activeShortcuts()!.groupId }} shortcuts</div>\n @for (s of activeShortcuts()!.shortcuts; track s.key) {\n <div class=\"shortcut-row\">\n <kbd class=\"shortcut-key\">Alt+{{ formatKey(s.key) }}</kbd>\n <span class=\"shortcut-desc text-text-primary\">{{ s.description }}</span>\n </div>\n }\n <!--\n Esc is documented rather than added (scanner-command-speed.prd.md D24/E4).\n It has always put the cursor in the command box, from anywhere and even\n while typing in a field \u2014 it was simply never written down, which is why\n it read as a missing feature. Worded for what it actually does: it also\n closes this panel, and it does not check whether a dialog is open.\n -->\n <div class=\"shortcut-row\">\n <kbd class=\"shortcut-key\">Esc</kbd>\n <span class=\"shortcut-desc text-text-primary\">Put the cursor in the command box, from anywhere</span>\n </div>\n <div class=\"shortcut-row shortcut-dismiss\">\n <kbd class=\"shortcut-key\">Alt+?</kbd>\n <span class=\"shortcut-desc text-secondary-info\">or</span>\n <kbd class=\"shortcut-key\">Esc</kbd>\n <span class=\"shortcut-desc text-secondary-info\">dismiss</span>\n </div>\n </div>\n </div>\n }\n @if (isThinking()) {\n <div class=\"thinking-overlay\">\n <mat-spinner diameter=\"48\" />\n </div>\n }\n</div>\n", styles: [".main-content{position:relative;height:100%;overflow:hidden}.content-area{height:100%;overflow-y:auto;padding:16px}.shortcut-overlay{position:absolute;inset:0;z-index:15;display:flex;align-items:center;justify-content:center;background-color:#0a0e1480}.shortcut-panel{background-color:var(--color-terminal-bg);border:1px solid var(--color-surface-border);border-radius:6px;padding:14px 18px;font-size:.8rem;box-shadow:0 4px 16px #00000080;min-width:220px}.shortcut-title{font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px;font-weight:500}.shortcut-row{display:flex;align-items:center;gap:10px;padding:3px 0}.shortcut-row.shortcut-dismiss{margin-top:6px;border-top:1px solid var(--color-surface-border);padding-top:8px}.shortcut-key{font-family:Roboto Mono,monospace;font-size:.75rem;background-color:#ffffff14;border:1px solid var(--color-surface-border);border-radius:3px;padding:1px 6px;min-width:70px;text-align:center}.shortcut-desc{font-size:.78rem}.thinking-overlay{position:absolute;inset:0;z-index:20;background-color:#0a0e14b3;display:flex;align-items:center;justify-content:center;pointer-events:all;cursor:wait}\n"] }]
|
|
2978
3083
|
}] });
|
|
2979
3084
|
|
|
2980
3085
|
class CommandTerminal {
|
|
@@ -3148,9 +3253,19 @@ class Login {
|
|
|
3148
3253
|
this.submitted = false;
|
|
3149
3254
|
this.hide = signal(true, /* @ts-ignore */
|
|
3150
3255
|
...(ngDevMode ? [{ debugName: "hide" }] : /* istanbul ignore next */ []));
|
|
3151
|
-
this.takeover = signal(true, /* @ts-ignore */
|
|
3152
|
-
...(ngDevMode ? [{ debugName: "takeover" }] : /* istanbul ignore next */ []));
|
|
3153
3256
|
this.errors = [];
|
|
3257
|
+
/**
|
|
3258
|
+
* Session identifiers the user has ticked for ending (multiple-devices D7).
|
|
3259
|
+
*
|
|
3260
|
+
* ⚠ THE TAKEOVER CHECKBOX THAT USED TO LIVE ON THIS FORM IS GONE, and its
|
|
3261
|
+
* removal is the point rather than a tidy-up. It defaulted to ON and sat on
|
|
3262
|
+
* the FIRST form, before anything was known about what was live — so once a
|
|
3263
|
+
* user deliberately holds sessions on two machines, one absent-minded submit
|
|
3264
|
+
* ended the session on the machine in the other room. The choice belongs
|
|
3265
|
+
* here, on the screen that lists what is actually running.
|
|
3266
|
+
*/
|
|
3267
|
+
this.selected = signal(new Set(), /* @ts-ignore */
|
|
3268
|
+
...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
3154
3269
|
this.USERNAME_REGEX = USERNAME_REGEX;
|
|
3155
3270
|
this.PASSWORD_REGEX = PASSWORD_REGEX;
|
|
3156
3271
|
this.appName = inject(SHELL_APP_NAME, { optional: true }) ?? 'Application';
|
|
@@ -3194,7 +3309,10 @@ class Login {
|
|
|
3194
3309
|
this.submitted = true;
|
|
3195
3310
|
const result = validateCredentials(this.model);
|
|
3196
3311
|
if (result === true) {
|
|
3197
|
-
|
|
3312
|
+
// A first attempt makes no claim about other sessions. If any exist the
|
|
3313
|
+
// server refuses, and the conflict panel below is where the decision is
|
|
3314
|
+
// made — with the sessions on screen.
|
|
3315
|
+
this.#doLogin(false);
|
|
3198
3316
|
}
|
|
3199
3317
|
else {
|
|
3200
3318
|
for (let ndx = 0; ndx < result.length; ndx++) {
|
|
@@ -3206,17 +3324,61 @@ class Login {
|
|
|
3206
3324
|
this.submitted = false;
|
|
3207
3325
|
}
|
|
3208
3326
|
}
|
|
3327
|
+
/** Whether individual sessions can be ticked: only when the server named them. */
|
|
3328
|
+
canEndIndividually() {
|
|
3329
|
+
return (this.authenticationService.conflictSessions() ?? []).some(s => !!s.sessionId);
|
|
3330
|
+
}
|
|
3331
|
+
isSelected(session) {
|
|
3332
|
+
return !!session.sessionId && this.selected().has(session.sessionId);
|
|
3333
|
+
}
|
|
3334
|
+
toggleSelected(session, checked) {
|
|
3335
|
+
if (!session.sessionId)
|
|
3336
|
+
return;
|
|
3337
|
+
const next = new Set(this.selected());
|
|
3338
|
+
if (checked)
|
|
3339
|
+
next.add(session.sessionId);
|
|
3340
|
+
else
|
|
3341
|
+
next.delete(session.sessionId);
|
|
3342
|
+
this.selected.set(next);
|
|
3343
|
+
}
|
|
3344
|
+
selectedCount() {
|
|
3345
|
+
return this.selected().size;
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* What to call a session on screen.
|
|
3349
|
+
*
|
|
3350
|
+
* Falls back to the application name, which is what this screen showed before
|
|
3351
|
+
* devices were recorded, and then to something plainly non-committal. It never
|
|
3352
|
+
* invents a device: a session that predates the column genuinely has none, and
|
|
3353
|
+
* guessing would make two machines look like one.
|
|
3354
|
+
*/
|
|
3355
|
+
deviceLabel(session) {
|
|
3356
|
+
return session.deviceDescription ?? session.appContext ?? 'Another session';
|
|
3357
|
+
}
|
|
3209
3358
|
onTakeOver() {
|
|
3210
3359
|
this.errors = [];
|
|
3360
|
+
this.selected.set(new Set());
|
|
3211
3361
|
this.#commandService.execute('login --force');
|
|
3212
3362
|
}
|
|
3363
|
+
onSignInAsWell() {
|
|
3364
|
+
this.errors = [];
|
|
3365
|
+
this.selected.set(new Set());
|
|
3366
|
+
this.#doLogin(false, { additional: true });
|
|
3367
|
+
}
|
|
3368
|
+
onEndSelected() {
|
|
3369
|
+
this.errors = [];
|
|
3370
|
+
const ids = [...this.selected()];
|
|
3371
|
+
this.selected.set(new Set());
|
|
3372
|
+
this.#doLogin(false, { endSessionIds: ids });
|
|
3373
|
+
}
|
|
3213
3374
|
onCancelConflict() {
|
|
3214
3375
|
this.authenticationService.clearConflict();
|
|
3376
|
+
this.selected.set(new Set());
|
|
3215
3377
|
this.submitted = false;
|
|
3216
3378
|
}
|
|
3217
|
-
#doLogin(force) {
|
|
3379
|
+
#doLogin(force, choice = {}) {
|
|
3218
3380
|
this.authenticationService
|
|
3219
|
-
.login(this.model, force)
|
|
3381
|
+
.login(this.model, force, choice)
|
|
3220
3382
|
.then(() => {
|
|
3221
3383
|
this.#commandService.echoCommand(`login ${this.model.username}`);
|
|
3222
3384
|
this.router.navigateByUrl(this.#resolvePostLoginUrl()).then(() => {
|
|
@@ -3231,7 +3393,7 @@ class Login {
|
|
|
3231
3393
|
});
|
|
3232
3394
|
}
|
|
3233
3395
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, deps: [{ token: AuthenticationService }, { token: i2$1.Router }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3234
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Login, isStandalone: true, selector: "shell-login", ngImport: i0, template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <
|
|
3396
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Login, isStandalone: true, selector: "shell-login", ngImport: i0, template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n </form>\n @if (errors.length > 0) {\n <div class=\"login-errors\">\n @for (error of errors; track error.ndx) {\n <div class=\"login-error text-loss\">{{ error.message }}</div>\n }\n </div>\n }\n @if (authenticationService.conflictSessions(); as sessions) {\n <div class=\"login-conflict\">\n <p class=\"text-secondary-info\">\n You are already signed in {{ sessions.length === 1 ? 'somewhere else' : 'on other devices' }}:\n </p>\n @for (session of sessions; track session.sessionId ?? $index) {\n <div class=\"login-conflict-session\">\n @if (canEndIndividually()) {\n <mat-checkbox\n [checked]=\"isSelected(session)\"\n [disabled]=\"!session.sessionId\"\n (change)=\"toggleSelected(session, $event.checked)\"\n [attr.aria-label]=\"'End ' + deviceLabel(session)\"\n ></mat-checkbox>\n }\n <span class=\"text-secondary-info\">\n <strong>{{ deviceLabel(session) }}</strong>\n <!-- signedInAtEpoch is when the password was typed. startEpoch is\n when the row was written, which renewal resets \u2014 so it is shown\n only when there is nothing better, and labelled for what it is. -->\n @if (session.signedInAtEpoch) {\n \u2014 since {{ session.signedInAtEpoch | date:'short' }}\n } @else {\n \u2014 last active {{ session.startEpoch | date:'short' }}\n }\n </span>\n </div>\n }\n <div class=\"login-conflict-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"onCancelConflict()\">Cancel</button>\n @if (selectedCount() > 0) {\n <button mat-stroked-button type=\"button\" (click)=\"onEndSelected()\">\n End {{ selectedCount() === 1 ? 'that one' : selectedCount() + ' of them' }} and sign in\n </button>\n }\n <button mat-stroked-button type=\"button\" (click)=\"onTakeOver()\">\n {{ sessions.length === 1 ? 'End it and sign in' : 'End them all and sign in' }}\n </button>\n @if (authenticationService.additionalSignInAllowed()) {\n <button mat-flat-button type=\"button\" (click)=\"onSignInAsWell()\">Sign in here as well</button>\n }\n </div>\n </div>\n }\n</div>\n", styles: [".login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center}.login-header{margin-bottom:32px}.login-header h2{font-size:1.5rem;font-weight:300;margin-bottom:8px}.login-form{display:flex;flex-direction:column;align-items:center;gap:8px;width:320px}.login-field{width:100%;text-align:left}.login-takeover{align-self:flex-start;margin-top:4px}.login-button{margin-top:8px;width:100%}.login-errors{margin-top:16px;max-width:320px}.login-conflict{margin-top:16px;max-width:320px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);font-size:.85rem;text-align:left}.login-conflict p{margin:0 0 8px}.login-conflict-session{display:flex;align-items:center;gap:8px;margin:4px 0}.login-conflict-actions{flex-wrap:wrap;display:flex;gap:8px;margin-top:12px;justify-content:flex-end}.login-error{padding:8px 12px;margin-bottom:4px;font-size:.85rem;border-radius:4px;background-color:var(--color-surface-light)}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i3.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i3.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i3.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i5.CdkAutofill, selector: "[cdkAutofill]", outputs: ["cdkAutofill"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i6.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i7.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: RegexValidatorDirective, selector: "[regexValidator]", inputs: ["regex"] }, { kind: "ngmodule", type: TextFieldModule }, { kind: "pipe", type: DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
3235
3397
|
}
|
|
3236
3398
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, decorators: [{
|
|
3237
3399
|
type: Component,
|
|
@@ -3247,7 +3409,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
3247
3409
|
RegexValidatorDirective,
|
|
3248
3410
|
TextFieldModule,
|
|
3249
3411
|
DatePipe,
|
|
3250
|
-
], template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <
|
|
3412
|
+
], template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n </form>\n @if (errors.length > 0) {\n <div class=\"login-errors\">\n @for (error of errors; track error.ndx) {\n <div class=\"login-error text-loss\">{{ error.message }}</div>\n }\n </div>\n }\n @if (authenticationService.conflictSessions(); as sessions) {\n <div class=\"login-conflict\">\n <p class=\"text-secondary-info\">\n You are already signed in {{ sessions.length === 1 ? 'somewhere else' : 'on other devices' }}:\n </p>\n @for (session of sessions; track session.sessionId ?? $index) {\n <div class=\"login-conflict-session\">\n @if (canEndIndividually()) {\n <mat-checkbox\n [checked]=\"isSelected(session)\"\n [disabled]=\"!session.sessionId\"\n (change)=\"toggleSelected(session, $event.checked)\"\n [attr.aria-label]=\"'End ' + deviceLabel(session)\"\n ></mat-checkbox>\n }\n <span class=\"text-secondary-info\">\n <strong>{{ deviceLabel(session) }}</strong>\n <!-- signedInAtEpoch is when the password was typed. startEpoch is\n when the row was written, which renewal resets \u2014 so it is shown\n only when there is nothing better, and labelled for what it is. -->\n @if (session.signedInAtEpoch) {\n \u2014 since {{ session.signedInAtEpoch | date:'short' }}\n } @else {\n \u2014 last active {{ session.startEpoch | date:'short' }}\n }\n </span>\n </div>\n }\n <div class=\"login-conflict-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"onCancelConflict()\">Cancel</button>\n @if (selectedCount() > 0) {\n <button mat-stroked-button type=\"button\" (click)=\"onEndSelected()\">\n End {{ selectedCount() === 1 ? 'that one' : selectedCount() + ' of them' }} and sign in\n </button>\n }\n <button mat-stroked-button type=\"button\" (click)=\"onTakeOver()\">\n {{ sessions.length === 1 ? 'End it and sign in' : 'End them all and sign in' }}\n </button>\n @if (authenticationService.additionalSignInAllowed()) {\n <button mat-flat-button type=\"button\" (click)=\"onSignInAsWell()\">Sign in here as well</button>\n }\n </div>\n </div>\n }\n</div>\n", styles: [".login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center}.login-header{margin-bottom:32px}.login-header h2{font-size:1.5rem;font-weight:300;margin-bottom:8px}.login-form{display:flex;flex-direction:column;align-items:center;gap:8px;width:320px}.login-field{width:100%;text-align:left}.login-takeover{align-self:flex-start;margin-top:4px}.login-button{margin-top:8px;width:100%}.login-errors{margin-top:16px;max-width:320px}.login-conflict{margin-top:16px;max-width:320px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);font-size:.85rem;text-align:left}.login-conflict p{margin:0 0 8px}.login-conflict-session{display:flex;align-items:center;gap:8px;margin:4px 0}.login-conflict-actions{flex-wrap:wrap;display:flex;gap:8px;margin-top:12px;justify-content:flex-end}.login-error{padding:8px 12px;margin-bottom:4px;font-size:.85rem;border-radius:4px;background-color:var(--color-surface-light)}\n"] }]
|
|
3251
3413
|
}], ctorParameters: () => [{ type: AuthenticationService }, { type: i2$1.Router }] });
|
|
3252
3414
|
|
|
3253
3415
|
class ConfirmDialog {
|