@nauticana/sail 1.1.32 → 1.1.34

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.
Files changed (42) hide show
  1. package/README.md +35 -19
  2. package/package.json +1 -1
  3. package/src/agent/component/approval_inbox.html +63 -0
  4. package/src/agent/component/approval_inbox.ts +91 -0
  5. package/src/agent/component/decision_timeline.html +4 -4
  6. package/src/agent/component/decision_timeline.ts +2 -0
  7. package/src/agent/component/prompt_section_editor.html +4 -3
  8. package/src/agent/component/prompt_section_editor.ts +12 -5
  9. package/src/agent/component/release_sections.html +1 -1
  10. package/src/agent/component/release_sections.ts +2 -1
  11. package/src/agent/component/studio_page.html +269 -0
  12. package/src/agent/component/studio_page.ts +346 -0
  13. package/src/agent/component/studio_tab.ts +14 -0
  14. package/src/agent/component/turn_event_list.html +9 -0
  15. package/src/agent/index.ts +6 -0
  16. package/src/agent/model/agent_studio.ts +122 -9
  17. package/src/agent/model/approval.ts +42 -0
  18. package/src/agent/model/conversation.ts +9 -1
  19. package/src/agent/model/decision.ts +2 -1
  20. package/src/agent/model/turn_event.ts +10 -0
  21. package/src/agent/service/agent_studio.service.ts +36 -1
  22. package/src/agent/service/approval.service.ts +18 -0
  23. package/src/agent/util/release_diff.ts +33 -0
  24. package/src/agent/util/studio_errors.ts +11 -0
  25. package/src/agent/util/turn_timeline.ts +7 -1
  26. package/src/component/login/account_deletion.html +17 -4
  27. package/src/component/login/account_deletion.ts +35 -28
  28. package/src/component/login/login_component.html +11 -0
  29. package/src/component/login/login_component.ts +2 -1
  30. package/src/component/login/social_login.ts +6 -5
  31. package/src/component/security/sessions.html +46 -0
  32. package/src/component/security/sessions.ts +45 -0
  33. package/src/component/security/twofactor_verify.html +12 -8
  34. package/src/component/security/twofactor_verify.ts +2 -1
  35. package/src/index.ts +2 -0
  36. package/src/model/appdata.ts +12 -0
  37. package/src/model/auth.ts +17 -2
  38. package/src/service/auth.service.ts +93 -48
  39. package/src/service/caption_pipe.ts +26 -0
  40. package/src/service/label.service.ts +5 -4
  41. package/src/service/rest_url.ts +3 -0
  42. package/src/util/errors.ts +7 -1
@@ -1,6 +1,7 @@
1
- /** Agent Studio `studio-v2` wire types. Field names are the backend's JSON names. */
1
+ /** Agent Studio wire types (Scout v1.2.7). Field names are the backend's JSON names. */
2
2
 
3
- export type AgentMergeMode = 'append' | 'replace';
3
+ /** R replace, A append, I intersect (non-prompt settings only). */
4
+ export type AgentMergeMode = 'R' | 'A' | 'I';
4
5
  export type AgentResetScope = 'agent_override' | 'type_default' | 'platform_baseline';
5
6
 
6
7
  export interface AgentSummary {
@@ -10,6 +11,7 @@ export interface AgentSummary {
10
11
  purpose: string;
11
12
  enabled: boolean;
12
13
  is_default: boolean;
14
+ archived: boolean;
13
15
  readiness: string;
14
16
  readiness_reason: string;
15
17
  type_defaults_revision: number;
@@ -35,10 +37,6 @@ export interface AgentModelSelection {
35
37
  video_model: string;
36
38
  }
37
39
 
38
- export interface AgentApprovalPolicy {
39
- require_approval: boolean;
40
- }
41
-
42
40
  /** One contribution to a prompt section. `editable` is response-only. */
43
41
  export interface AgentPromptLayer {
44
42
  scope_id: string;
@@ -48,6 +46,8 @@ export interface AgentPromptLayer {
48
46
  editable: boolean;
49
47
  instruction: string;
50
48
  output: string;
49
+ /** Who last edited the layer; absent on the baseline. */
50
+ bound_by?: number;
51
51
  }
52
52
 
53
53
  export interface AgentPromptSection {
@@ -77,7 +77,6 @@ export interface AgentDraft {
77
77
  display_name: string;
78
78
  enabled: boolean;
79
79
  is_default: boolean;
80
- approval_policy: AgentApprovalPolicy;
81
80
  models: AgentModelSelection;
82
81
  languages: AgentLanguageDraft[];
83
82
  agent_scope_id?: string;
@@ -103,6 +102,7 @@ export interface AgentTestResult {
103
102
  latency_ms: number;
104
103
  input_tokens: number;
105
104
  output_tokens: number;
105
+ /** Cost in minor units. */
106
106
  credits: number;
107
107
  sections: string[];
108
108
  }
@@ -110,6 +110,10 @@ export interface AgentTestResult {
110
110
  export interface AgentPublishRequest {
111
111
  agent_name: string;
112
112
  change_summary: string;
113
+ /** 1–99 deploys the release as a canary. */
114
+ canary_percentage?: number;
115
+ /** RFC 3339; a future time schedules the release. */
116
+ effective_at?: string;
113
117
  expected_agent_revision: number;
114
118
  expected_type_defaults_revision: number;
115
119
  }
@@ -137,6 +141,7 @@ export interface AgentSetDefaultRequest {
137
141
  export interface AgentSetEnabledRequest {
138
142
  agent_name: string;
139
143
  enabled: boolean;
144
+ reason?: string;
140
145
  expected_agent_revision: number;
141
146
  }
142
147
 
@@ -151,19 +156,41 @@ export interface AgentRelease {
151
156
  version: number;
152
157
  enabled: boolean;
153
158
  models: AgentModelSelection;
154
- require_approval: boolean;
155
159
  definition_digest: string;
156
160
  change_summary: string;
157
161
  published_by: number;
158
162
  published_at: string;
159
163
  active: boolean;
160
164
  languages: string[];
165
+ activation?: ReleaseActivation;
166
+ }
167
+
168
+ /** A scheduled publish; state P pending, A activated, R refused, X cancelled. */
169
+ export interface ReleaseActivation {
170
+ id: number;
171
+ agent_name: string;
172
+ version: number;
173
+ definition_digest: string;
174
+ canary_percentage?: number;
175
+ effective_at: string;
176
+ state: 'P' | 'A' | 'R' | 'X';
177
+ scheduled_by: string;
178
+ scheduled_at: string;
179
+ attempts: number;
180
+ next_attempt_at?: string;
181
+ reason?: string;
182
+ detail?: string;
183
+ decided_by?: string;
184
+ decided_at?: string;
161
185
  }
162
186
 
163
187
  export interface AgentAuditEvent {
164
188
  event: string;
165
189
  detail: string;
166
- user_id: number;
190
+ actor_kind?: string;
191
+ actor_id?: string;
192
+ /** Set only when a person acted. */
193
+ user_id?: number;
167
194
  event_time: string;
168
195
  }
169
196
 
@@ -196,3 +223,89 @@ export interface StudioModel {
196
223
  image_credits: number;
197
224
  video_credits_per_sec: number;
198
225
  }
226
+
227
+ /** One optional Studio API the backend mounted. Unknown ids are ignored; grants still decide. */
228
+ export interface StudioFeature {
229
+ id: string;
230
+ version: number;
231
+ actions: string[];
232
+ /** Authorization object of the actions; empty is AGENT_STUDIO. */
233
+ object?: string;
234
+ }
235
+
236
+ /** `copy_from` and `template_id` are exclusive. */
237
+ export interface AgentCreateRequest {
238
+ agent_type: string;
239
+ display_name: string;
240
+ copy_from?: string;
241
+ template_id?: string;
242
+ template_version?: number;
243
+ references?: Record<string, Record<string, string>>;
244
+ idempotency_key: string;
245
+ }
246
+
247
+ export interface AgentRenameRequest {
248
+ agent_name: string;
249
+ display_name: string;
250
+ expected_agent_revision: number;
251
+ }
252
+
253
+ export interface AgentArchiveRequest {
254
+ agent_name: string;
255
+ expected_agent_revision: number;
256
+ }
257
+
258
+ export interface AgentSettingSource {
259
+ scope_id: string;
260
+ scope_kind: string;
261
+ resource_version: string;
262
+ merge_mode: AgentMergeMode;
263
+ sealed: boolean;
264
+ }
265
+
266
+ /** One binding on the agent's scope chain; `editable` marks the agent's and its type's scopes. */
267
+ export interface AgentSetting {
268
+ scope_id: string;
269
+ scope_kind: string;
270
+ editable: boolean;
271
+ resource_kind: string;
272
+ resource_id: string;
273
+ resource_version: string;
274
+ merge_mode: AgentMergeMode;
275
+ sealed: boolean;
276
+ value: unknown;
277
+ }
278
+
279
+ export interface AgentEffectiveSetting {
280
+ resource_kind: string;
281
+ resource_id: string;
282
+ value: unknown;
283
+ source: AgentSettingSource;
284
+ superseded?: AgentSettingSource[];
285
+ }
286
+
287
+ /** Bindings other than prompt sections; `problem` says why they do not compile. */
288
+ export interface AgentSettings {
289
+ agent_name: string;
290
+ agent_scope_id: string;
291
+ type_scope_id: string;
292
+ bindings: AgentSetting[];
293
+ effective: AgentEffectiveSetting[];
294
+ problem?: string;
295
+ expected_agent_revision: number;
296
+ expected_type_defaults_revision: number;
297
+ }
298
+
299
+ /** Binds, or with `remove` ends, one resource at the agent or type scope. */
300
+ export interface AgentSettingChange {
301
+ agent_name: string;
302
+ scope_id: string;
303
+ resource_kind: string;
304
+ resource_id: string;
305
+ merge_mode?: AgentMergeMode;
306
+ sealed: boolean;
307
+ value?: unknown;
308
+ remove: boolean;
309
+ expected_agent_revision: number;
310
+ expected_type_defaults_revision: number;
311
+ }
@@ -0,0 +1,42 @@
1
+ /** Scout approval desk wire types (v1.2.7). Codes render through backend captions. */
2
+
3
+ /** A approved, X rejected, E edited (tool-call proposals only, with revised arguments). */
4
+ export type ApprovalVerdict = 'A' | 'X' | 'E';
5
+
6
+ export interface ApproverReference {
7
+ kind: string;
8
+ ref: string;
9
+ }
10
+
11
+ /** One decision a reviewer owes; evidence stays behind its own authorization. */
12
+ export interface ApprovalRequest {
13
+ request_id: string;
14
+ execution_step_id: number;
15
+ conversation_id: string;
16
+ scope_id?: string;
17
+ rule_id: string;
18
+ action: string;
19
+ resource: string;
20
+ output_class: string;
21
+ risk_tier: string;
22
+ summary: string;
23
+ proposed_digest: string;
24
+ status: string;
25
+ deadline_at?: string;
26
+ created_at: string;
27
+ revises_step_id?: number;
28
+ rule_source: string;
29
+ route_id?: string;
30
+ route_step: number;
31
+ approver_reference?: ApproverReference;
32
+ }
33
+
34
+ export interface ApprovalResolveRequest {
35
+ request_id: string;
36
+ execution_step_id: number;
37
+ verdict: ApprovalVerdict;
38
+ proposed_digest: string;
39
+ reason?: string;
40
+ /** The JSON object an `E` verdict runs the tool call with instead. */
41
+ revised_arguments?: Record<string, unknown>;
42
+ }
@@ -4,7 +4,15 @@ import { InjectionToken } from '@angular/core';
4
4
  export interface ConversationTurnRequest {
5
5
  request_id: string;
6
6
  conversation_id: string;
7
- agent_id: string;
7
+ /** The agent type alias; its default agent serves a new conversation. */
8
+ agent_type: string;
9
+ /** One agent of that type instead of its default. */
10
+ agent_name?: string;
11
+ /** Pins a new conversation to one release; needs `release_reason`. */
12
+ release?: string;
13
+ release_reason?: string;
14
+ /** The product's opaque reference to the work the turn serves. */
15
+ work_ref?: string;
8
16
  /** Opaque product payload; the backend forwards it to the agent unread. */
9
17
  input: unknown;
10
18
  }
@@ -1,6 +1,7 @@
1
1
  import { ObjectRef } from './turn_event';
2
2
 
3
- export type DecisionOutcome = 'allow' | 'deny' | 'pending';
3
+ /** A allowed, D denied, P pending (`audit_decision_outcome`). */
4
+ export type DecisionOutcome = 'A' | 'D' | 'P';
4
5
 
5
6
  export interface PrincipalRef {
6
7
  kind: string;
@@ -10,6 +10,7 @@ export const TURN_EVENT_KINDS = [
10
10
  'effect',
11
11
  'progress',
12
12
  'result',
13
+ 'citations',
13
14
  'extension',
14
15
  ] as const;
15
16
 
@@ -73,6 +74,14 @@ export interface TurnExtensionEvent {
73
74
  payload?: unknown;
74
75
  }
75
76
 
77
+ export interface Citation {
78
+ url: string;
79
+ domain?: string;
80
+ title?: string;
81
+ snippet?: string;
82
+ position: number;
83
+ }
84
+
76
85
  export interface TurnEvent {
77
86
  v: number;
78
87
  kind: TurnEventKind;
@@ -83,6 +92,7 @@ export interface TurnEvent {
83
92
  effect?: TurnEffectEvent;
84
93
  progress?: TurnProgressEvent;
85
94
  extension?: TurnExtensionEvent;
95
+ citations?: Citation[];
86
96
  }
87
97
 
88
98
  const knownKinds: ReadonlySet<string> = new Set(TURN_EVENT_KINDS);
@@ -2,27 +2,38 @@ import { Injectable } from '@angular/core';
2
2
  import { Observable } from 'rxjs';
3
3
  import { BaseRestService } from '../../service/base_rest.service';
4
4
  import {
5
+ AgentArchiveRequest,
5
6
  AgentAuditEvent,
7
+ AgentCreateRequest,
6
8
  AgentDraft,
7
9
  AgentEnabledState,
8
10
  AgentPublishRequest,
9
11
  AgentRelease,
10
12
  AgentReleaseSection,
13
+ AgentRenameRequest,
11
14
  AgentResetRequest,
12
15
  AgentRestoreRequest,
13
16
  AgentSetDefaultRequest,
14
17
  AgentSetEnabledRequest,
18
+ AgentSettingChange,
19
+ AgentSettings,
15
20
  AgentSummary,
16
21
  AgentTestRequest,
17
22
  AgentTestResult,
23
+ StudioFeature,
18
24
  StudioModel,
19
25
  } from '../model/agent_studio';
20
26
 
21
27
  const studioBasePath = '/api/agent-studio/';
22
28
 
23
- /** Client for the `studio-v2` Agent Studio routes, which are fixed and unversioned. */
29
+ /** Client for Scout's core Agent Studio routes, which are fixed and unversioned. */
24
30
  @Injectable({ providedIn: 'root' })
25
31
  export class AgentStudioService extends BaseRestService {
32
+ /** The optional Studio APIs the backend mounted; `agents` is the core set. */
33
+ features(): Observable<StudioFeature[]> {
34
+ return this.http.get<StudioFeature[]>(this.studioUrl('features'));
35
+ }
36
+
26
37
  listAgents(): Observable<AgentSummary[]> {
27
38
  return this.http.get<AgentSummary[]>(this.studioUrl('agents'));
28
39
  }
@@ -36,6 +47,30 @@ export class AgentStudioService extends BaseRestService {
36
47
  return this.http.post<AgentDraft>(this.studioUrl('draft'), draft);
37
48
  }
38
49
 
50
+ create(request: AgentCreateRequest): Observable<AgentSummary> {
51
+ return this.http.post<AgentSummary>(this.studioUrl('create'), request);
52
+ }
53
+
54
+ rename(request: AgentRenameRequest): Observable<AgentSummary> {
55
+ return this.http.post<AgentSummary>(this.studioUrl('rename'), request);
56
+ }
57
+
58
+ archive(request: AgentArchiveRequest): Observable<AgentSummary> {
59
+ return this.http.post<AgentSummary>(this.studioUrl('archive'), request);
60
+ }
61
+
62
+ unarchive(request: AgentArchiveRequest): Observable<AgentSummary> {
63
+ return this.http.post<AgentSummary>(this.studioUrl('unarchive'), request);
64
+ }
65
+
66
+ settings(agentName: string): Observable<AgentSettings> {
67
+ return this.http.get<AgentSettings>(this.studioUrl('settings'), { params: { agent_name: agentName } });
68
+ }
69
+
70
+ saveSetting(change: AgentSettingChange): Observable<AgentSettings> {
71
+ return this.http.post<AgentSettings>(this.studioUrl('setting'), change);
72
+ }
73
+
39
74
  setEnabled(request: AgentSetEnabledRequest): Observable<AgentEnabledState> {
40
75
  return this.http.post<AgentEnabledState>(this.studioUrl('enabled'), request);
41
76
  }
@@ -0,0 +1,18 @@
1
+ import { Injectable } from '@angular/core';
2
+ import { Observable } from 'rxjs';
3
+ import { BaseRestService } from '../../service/base_rest.service';
4
+ import { ApprovalRequest, ApprovalResolveRequest } from '../model/approval';
5
+
6
+ /** Client for Scout's approval desk under the fixed `/api/approval/` path. */
7
+ @Injectable({ providedIn: 'root' })
8
+ export class ApprovalService extends BaseRestService {
9
+ /** The requests the signed-in person may decide now, soonest deadline first. */
10
+ pending(): Observable<ApprovalRequest[]> {
11
+ return this.http.get<ApprovalRequest[]>(this.url('/api/approval/pending'));
12
+ }
13
+
14
+ /** 409 when the request was reassigned or decided differently since it was read. */
15
+ resolve(request: ApprovalResolveRequest): Observable<ApprovalRequest> {
16
+ return this.http.post<ApprovalRequest>(this.url('/api/approval/resolve'), request);
17
+ }
18
+ }
@@ -0,0 +1,33 @@
1
+ import { AgentReleaseSection } from '../model/agent_studio';
2
+
3
+ export interface ReleaseSectionDiff {
4
+ language_code: string;
5
+ prompt_header_id: number;
6
+ caption: string;
7
+ status: 'added' | 'changed' | 'removed';
8
+ old_text: string;
9
+ new_text: string;
10
+ }
11
+
12
+ /**
13
+ * Section changes from `prior` to `current`, keyed by language and prompt header. Caption and
14
+ * description are part of the compiled prompt, so they count; so does a pure reordering.
15
+ */
16
+ export function diffReleaseSections(prior: AgentReleaseSection[], current: AgentReleaseSection[]): ReleaseSectionDiff[] {
17
+ const key = (s: AgentReleaseSection) => `${s.language_code}#${s.prompt_header_id}`;
18
+ const text = (s: AgentReleaseSection) =>
19
+ `${s.description ? `${s.caption} — ${s.description}` : s.caption}\n${s.output ? `${s.instruction}\n\nOutput: ${s.output}` : s.instruction}`;
20
+ const row = (s: AgentReleaseSection, status: ReleaseSectionDiff['status'], old_text: string, new_text: string): ReleaseSectionDiff =>
21
+ ({ language_code: s.language_code, prompt_header_id: s.prompt_header_id, caption: s.caption, status, old_text, new_text });
22
+ const remaining = new Map(prior.map((s) => [key(s), s]));
23
+ const rows: ReleaseSectionDiff[] = [];
24
+ for (const section of current) {
25
+ const old = remaining.get(key(section));
26
+ remaining.delete(key(section));
27
+ if (!old) rows.push(row(section, 'added', '', text(section)));
28
+ else if (text(old) !== text(section)) rows.push(row(section, 'changed', text(old), text(section)));
29
+ else if (old.sequence !== section.sequence) rows.push(row(section, 'changed', `position ${old.sequence}`, `position ${section.sequence}`));
30
+ }
31
+ for (const old of remaining.values()) rows.push(row(old, 'removed', text(old), ''));
32
+ return rows;
33
+ }
@@ -1,3 +1,4 @@
1
+ import { errorDetail } from '../../util/errors';
1
2
  import { AgentFieldError, AgentValidationProblem } from '../model/agent_studio';
2
3
 
3
4
  /**
@@ -59,3 +60,13 @@ export function retryAfterSeconds(err: unknown): number | undefined {
59
60
  const seconds = Number(raw);
60
61
  return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
61
62
  }
63
+
64
+ /** One message for a failed Studio call: the conflict cause, the field errors, or the detail. */
65
+ export function studioErrorMessage(err: unknown, fallback = 'Request failed.'): string {
66
+ const cause = studioConflictCause(err);
67
+ if (cause === 'revision') return 'This agent changed since you opened it. Reload and reapply your edits.';
68
+ if (cause === 'sealed') return 'A wider scope sealed this section, so narrower layers cannot change it.';
69
+ const fields = studioFieldErrors(err);
70
+ if (fields.length > 0) return fields.map((f) => `${f.field}: ${f.message}`).join('; ');
71
+ return errorDetail(err, fallback);
72
+ }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Citation,
2
3
  EvidenceRef,
3
4
  TurnApprovalEvent,
4
5
  TurnEffectEvent,
@@ -31,6 +32,7 @@ export type TurnTimelineItem =
31
32
  | { kind: 'evidence'; refs: EvidenceRef[] }
32
33
  | { kind: 'progress'; progress: TurnProgressEvent }
33
34
  | { kind: 'extension'; extension: TurnExtensionEvent }
35
+ | { kind: 'citations'; citations: Citation[] }
34
36
  | { kind: 'result' };
35
37
 
36
38
  function errorClassOf(tool: TurnToolEvent): string | undefined {
@@ -62,7 +64,8 @@ export function buildTurnTimeline(events: readonly unknown[]): TurnTimelineItem[
62
64
  };
63
65
 
64
66
  const applyApproval = (approval: TurnApprovalEvent | undefined, state: ToolCallApproval): void => {
65
- if (!approval) return;
67
+ // A turn-level suspension carries an empty approval; turn status shows it, not a tool row.
68
+ if (!approval?.call_id) return;
66
69
  const call = callFor(approval);
67
70
  call.approval = state;
68
71
  call.decision = approval.decision;
@@ -106,6 +109,9 @@ export function buildTurnTimeline(events: readonly unknown[]): TurnTimelineItem[
106
109
  case 'extension':
107
110
  if (event.extension) items.push({ kind: 'extension', extension: event.extension });
108
111
  break;
112
+ case 'citations':
113
+ if (event.citations?.length) items.push({ kind: 'citations', citations: event.citations });
114
+ break;
109
115
  case 'result':
110
116
  items.push({ kind: 'result' });
111
117
  break;
@@ -13,10 +13,23 @@
13
13
  <input matInput formControlName="confirm" autocomplete="off" required>
14
14
  </mat-form-field>
15
15
 
16
- <mat-form-field appearance="outline">
17
- <mat-label>Confirm your password</mat-label>
18
- <input matInput type="password" formControlName="password" autocomplete="current-password" required>
19
- </mat-form-field>
16
+ @if (method() === 'password') {
17
+ <mat-form-field appearance="outline">
18
+ <mat-label>Confirm your password</mat-label>
19
+ <input matInput type="password" formControlName="credential" autocomplete="current-password" required>
20
+ </mat-form-field>
21
+ } @else {
22
+ <button matButton type="button" [disabled]="loading()" (click)="sendCode()">
23
+ {{ codeSent() ? 'Resend code' : 'Send me a code' }}
24
+ </button>
25
+ <mat-form-field appearance="outline">
26
+ <mat-label>One-time code</mat-label>
27
+ <input matInput formControlName="credential" inputmode="numeric" autocomplete="one-time-code" required>
28
+ </mat-form-field>
29
+ }
30
+ <button matButton type="button" (click)="toggleMethod()">
31
+ {{ method() === 'password' ? 'Use a one-time code instead' : 'Use my password instead' }}
32
+ </button>
20
33
 
21
34
  <mat-form-field appearance="outline">
22
35
  <mat-label>Reason (optional)</mat-label>
@@ -1,7 +1,7 @@
1
1
  import {
2
- ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input, signal,
2
+ ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input, linkedSignal, signal,
3
3
  } from '@angular/core';
4
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
+ import { toSignal } from '@angular/core/rxjs-interop';
5
5
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
6
6
  import { MatButtonModule } from '@angular/material/button';
7
7
  import { MatFormFieldModule } from '@angular/material/form-field';
@@ -10,18 +10,17 @@ import { Router, RouterLink } from '@angular/router';
10
10
  import { BaseAsync } from '../abstract/base_async';
11
11
  import { BaseAuthService } from '../../service/auth.service';
12
12
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
13
+ import { ReauthCredentials } from '../../model/auth';
13
14
 
14
15
  /**
15
16
  * AccountDeletionComponent — App Store / Play Store compliance surface.
16
17
  *
17
- * Typed-confirmation UX (standard anti-fat-finger pattern): user must type
18
- * the literal `confirmationText` (default "DELETE") before the destructive
19
- * button enables. Optional `reason` textarea forwarded to the backend's
20
- * DELETE /api/user/account for the audit trail.
21
- *
22
- * On success: local session cleared by BaseAuthService.deleteAccount; this
23
- * component then navigates to `config.accountDeletedRoute` (default
24
- * `/login/local`).
18
+ * The destructive button enables once the user types `confirmationText`
19
+ * (default "DELETE") and re-authenticates with a password or, for accounts
20
+ * without one, a one-time code (`reauthMethod`, switchable in the form).
21
+ * The optional reason is forwarded to DELETE /api/user/account for audit.
22
+ * On success the session is cleared and the router goes to
23
+ * `config.accountDeletedRoute` (default `/login/local`).
25
24
  */
26
25
  @Component({
27
26
  selector: 'sail-account-deletion',
@@ -44,37 +43,45 @@ export class AccountDeletionComponent extends BaseAsync {
44
43
  inject(SAIL_GUI_CONFIG, { optional: true }) ?? DEFAULT_CONFIG;
45
44
 
46
45
  readonly confirmationText = input('DELETE');
46
+ readonly reauthMethod = input<'password' | 'code'>('password');
47
+ readonly method = linkedSignal(() => this.reauthMethod());
48
+ readonly codeSent = signal(false);
47
49
 
48
50
  readonly form = this.fb.nonNullable.group({
49
- confirm: ['', Validators.required],
50
- password: ['', Validators.required], // re-auth gate required by keel
51
- reason: [''],
51
+ confirm: ['', Validators.required],
52
+ credential: ['', Validators.required],
53
+ reason: [''],
52
54
  });
53
55
 
54
- readonly typedValue = signal('');
55
- private readonly passwordValue = signal('');
56
+ private readonly confirmValue = toSignal(this.form.controls.confirm.valueChanges, { initialValue: '' });
57
+ private readonly credentialValue = toSignal(this.form.controls.credential.valueChanges, { initialValue: '' });
56
58
  readonly canSubmit = computed(() =>
57
- this.typedValue() === this.confirmationText() &&
58
- !!this.passwordValue() &&
59
+ this.confirmValue() === this.confirmationText() &&
60
+ !!this.credentialValue() &&
59
61
  !this.loading(),
60
62
  );
61
63
 
62
- constructor() {
63
- super();
64
- this.form.controls.confirm.valueChanges
65
- .pipe(takeUntilDestroyed())
66
- .subscribe((v) => this.typedValue.set(v ?? ''));
67
- this.form.controls.password.valueChanges
68
- .pipe(takeUntilDestroyed())
69
- .subscribe((v) => this.passwordValue.set(v ?? ''));
64
+ toggleMethod() {
65
+ this.method.update((m) => m === 'password' ? 'code' : 'password');
66
+ this.codeSent.set(false);
67
+ this.form.controls.credential.reset();
68
+ this.clearMessages();
69
+ }
70
+
71
+ sendCode() {
72
+ this.run(this.auth.sendReauthCode(), (res) => {
73
+ this.codeSent.set(true);
74
+ this.successMessage.set(`A code was sent to your ${res.channel}.`);
75
+ }, 'Failed to send a code. Please try again.');
70
76
  }
71
77
 
72
78
  onDelete() {
73
79
  if (!this.canSubmit()) return;
74
- const password = this.form.controls.password.value;
75
- const reason = this.form.controls.reason.value?.trim() || undefined;
80
+ const credential = this.form.controls.credential.value;
81
+ const reauth: ReauthCredentials = this.method() === 'code' ? { reauthCode: credential } : { password: credential };
82
+ const reason = this.form.controls.reason.value.trim() || undefined;
76
83
  this.run(
77
- this.auth.deleteAccount({ password }, reason),
84
+ this.auth.deleteAccount(reauth, reason),
78
85
  () => this.router.navigateByUrl(this.guiConfig.accountDeletedRoute ?? '/login/local'),
79
86
  'Failed to delete account. Please try again.',
80
87
  );
@@ -15,6 +15,17 @@
15
15
  @if (sessionHandoffError()) {
16
16
  <p class="auth-error" role="alert">{{ sessionHandoffError() }}</p>
17
17
  }
18
+ @if (signInPrompt(); as prompt) {
19
+ <p class="auth-message" role="status">
20
+ {{ prompt === 'login' ? 'Sign in again to continue.' : 'Choose the account to continue with.' }}
21
+ </p>
22
+ <div class="auth-actions">
23
+ @if (prompt === 'select_account') {
24
+ <button matButton="elevated" class="primary" type="button" (click)="auth.continueSession()">Continue with the signed-in account</button>
25
+ }
26
+ <button matButton type="button" (click)="auth.signOutHere()">Sign out of the current account</button>
27
+ </div>
28
+ }
18
29
  <form [formGroup]="loginForm" (ngSubmit)="login()" class="auth-form">
19
30
  <mat-form-field appearance="outline">
20
31
  <mat-label>Username</mat-label>
@@ -27,7 +27,7 @@ import { SsoLoginComponent } from '../sso/sso_login';
27
27
  ],
28
28
  })
29
29
  export class LoginComponent extends BaseAsync {
30
- private auth = inject(BaseAuthService);
30
+ protected readonly auth = inject(BaseAuthService);
31
31
  protected fb = inject(FormBuilder);
32
32
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
33
33
  private readonly queryParams = inject(ActivatedRoute).snapshot.queryParamMap;
@@ -35,6 +35,7 @@ export class LoginComponent extends BaseAsync {
35
35
  protected readonly passwordChanged = this.queryParams.get('passwordChanged') === 'true';
36
36
  /** OAuth session hand-off failure from BaseAuthService, shown below. */
37
37
  protected readonly sessionHandoffError = this.auth.sessionHandoffError;
38
+ protected readonly signInPrompt = this.auth.signInPrompt;
38
39
 
39
40
  /**
40
41
  * Auto-show the embedded SocialLoginComponent whenever at least one