e2e observation fixes
This commit is contained in:
@@ -82,6 +82,11 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
const accessToken = this.authSession.session()?.tokens.accessToken;
|
||||
if (accessToken) {
|
||||
headers = headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
if (projectId) headers = headers.set('X-Stella-Project', projectId);
|
||||
|
||||
return headers;
|
||||
|
||||
@@ -82,6 +82,14 @@ import {
|
||||
RollbackPolicyResponse,
|
||||
} from './policy-engine.models';
|
||||
|
||||
interface GovernanceSealedModeStatusResponse {
|
||||
readonly isSealed: boolean;
|
||||
readonly sealedAt?: string | null;
|
||||
readonly lastUnsealedAt?: string | null;
|
||||
readonly trustRoots?: readonly string[];
|
||||
readonly lastVerifiedAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy Engine API interface for dependency injection.
|
||||
*/
|
||||
@@ -441,7 +449,25 @@ export class PolicyEngineHttpClient implements PolicyEngineApi {
|
||||
|
||||
getSealedStatus(options: Pick<PolicyQueryOptions, 'tenantId' | 'traceId'>): Observable<SealedModeStatus> {
|
||||
const headers = this.buildHeaders(options);
|
||||
return this.http.get<SealedModeStatus>(`${this.baseUrl}/system/airgap/status`, { headers });
|
||||
let params = new HttpParams();
|
||||
if (options.tenantId) {
|
||||
params = params.set('tenantId', options.tenantId);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<GovernanceSealedModeStatusResponse>(
|
||||
`${this.baseUrl}/api/v1/governance/sealed-mode/status`,
|
||||
{ headers, params }
|
||||
)
|
||||
.pipe(
|
||||
map((response): SealedModeStatus => ({
|
||||
isSealed: response.isSealed,
|
||||
sealedAt: response.sealedAt ?? null,
|
||||
unsealedAt: response.lastUnsealedAt ?? null,
|
||||
trustRoots: [...(response.trustRoots ?? [])],
|
||||
lastVerifiedAt: response.lastVerifiedAt ?? null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
verifyBundle(request: BundleVerifyRequest, options: Pick<PolicyQueryOptions, 'tenantId' | 'traceId'>): Observable<BundleVerifyResponse> {
|
||||
|
||||
@@ -299,6 +299,12 @@ const MOCK_AUDIT_EVENTS: GovernanceAuditEvent[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const RISK_PROFILE_ID_ALIASES: Readonly<Record<string, string>> = {
|
||||
default: 'profile-default',
|
||||
strict: 'profile-strict',
|
||||
dev: 'profile-dev',
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Policy Governance API implementation.
|
||||
*/
|
||||
@@ -317,6 +323,20 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
|
||||
lastVerifiedAt: '2025-12-28T12:00:00Z',
|
||||
};
|
||||
|
||||
private canonicalProfileId(profileId: string): string {
|
||||
const normalized = profileId.trim();
|
||||
if (this.riskProfiles.some((profile) => profile.id === normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return RISK_PROFILE_ID_ALIASES[normalized.toLowerCase()] ?? normalized;
|
||||
}
|
||||
|
||||
private findProfileIndex(profileId: string): number {
|
||||
const canonicalId = this.canonicalProfileId(profileId);
|
||||
return this.riskProfiles.findIndex((profile) => profile.id === canonicalId);
|
||||
}
|
||||
|
||||
// Risk Budget
|
||||
getRiskBudgetDashboard(options: GovernanceQueryOptions): Observable<RiskBudgetDashboard> {
|
||||
const dashboard: RiskBudgetDashboard = {
|
||||
@@ -628,7 +648,7 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
|
||||
}
|
||||
|
||||
getRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
||||
const profile = this.riskProfiles.find((p) => p.id === profileId);
|
||||
const profile = this.riskProfiles.find((p) => p.id === this.canonicalProfileId(profileId));
|
||||
if (!profile) {
|
||||
return throwError(() => new Error(`Profile ${profileId} not found`));
|
||||
}
|
||||
@@ -657,14 +677,15 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
|
||||
}
|
||||
|
||||
updateRiskProfile(profileId: string, profile: Partial<RiskProfileGov>, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
||||
const idx = this.riskProfiles.findIndex((p) => p.id === profileId);
|
||||
const idx = this.findProfileIndex(profileId);
|
||||
if (idx < 0) {
|
||||
return throwError(() => new Error(`Profile ${profileId} not found`));
|
||||
}
|
||||
const canonicalId = this.riskProfiles[idx].id;
|
||||
const updated: RiskProfileGov = {
|
||||
...this.riskProfiles[idx],
|
||||
...profile,
|
||||
id: profileId,
|
||||
id: canonicalId,
|
||||
modifiedAt: new Date().toISOString(),
|
||||
modifiedBy: 'current-user',
|
||||
};
|
||||
@@ -673,12 +694,13 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
|
||||
}
|
||||
|
||||
deleteRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<void> {
|
||||
this.riskProfiles = this.riskProfiles.filter((p) => p.id !== profileId);
|
||||
const canonicalId = this.canonicalProfileId(profileId);
|
||||
this.riskProfiles = this.riskProfiles.filter((p) => p.id !== canonicalId);
|
||||
return of(undefined).pipe(delay(100));
|
||||
}
|
||||
|
||||
activateRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
||||
const idx = this.riskProfiles.findIndex((p) => p.id === profileId);
|
||||
const idx = this.findProfileIndex(profileId);
|
||||
if (idx < 0) {
|
||||
return throwError(() => new Error(`Profile ${profileId} not found`));
|
||||
}
|
||||
@@ -687,7 +709,7 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
|
||||
}
|
||||
|
||||
deprecateRiskProfile(profileId: string, reason: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
||||
const idx = this.riskProfiles.findIndex((p) => p.id === profileId);
|
||||
const idx = this.findProfileIndex(profileId);
|
||||
if (idx < 0) {
|
||||
return throwError(() => new Error(`Profile ${profileId} not found`));
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export type AuthStatus =
|
||||
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
|
||||
|
||||
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
|
||||
export const FULL_SESSION_STORAGE_KEY = 'stellaops.auth.session.full';
|
||||
|
||||
export type AuthErrorReason =
|
||||
| 'invalid_state'
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
|
||||
import {
|
||||
AuthSession,
|
||||
AuthTokens,
|
||||
FULL_SESSION_STORAGE_KEY,
|
||||
SESSION_STORAGE_KEY,
|
||||
} from './auth-session.model';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
|
||||
describe('AuthSessionStore', () => {
|
||||
let store: AuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
function createStore(): AuthSessionStore {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [AuthSessionStore],
|
||||
});
|
||||
store = TestBed.inject(AuthSessionStore);
|
||||
});
|
||||
return TestBed.inject(AuthSessionStore);
|
||||
}
|
||||
|
||||
it('persists minimal metadata when session is set', () => {
|
||||
function createSession(expiresAtEpochMs: number = Date.now() + 120_000): AuthSession {
|
||||
const tokens: AuthTokens = {
|
||||
accessToken: 'token-abc',
|
||||
expiresAtEpochMs: Date.now() + 120_000,
|
||||
expiresAtEpochMs,
|
||||
refreshToken: 'refresh-xyz',
|
||||
scope: 'openid ui.read',
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
|
||||
const session: AuthSession = {
|
||||
return {
|
||||
tokens,
|
||||
identity: {
|
||||
subject: 'user-123',
|
||||
@@ -39,6 +44,15 @@ describe('AuthSessionStore', () => {
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
it('persists metadata and full session when session is set', () => {
|
||||
const session = createSession();
|
||||
|
||||
store.setSession(session);
|
||||
|
||||
@@ -48,8 +62,42 @@ describe('AuthSessionStore', () => {
|
||||
expect(parsed.subject).toBe('user-123');
|
||||
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
|
||||
expect(parsed.tenantId).toBe('tenant-default');
|
||||
expect(sessionStorage.getItem(FULL_SESSION_STORAGE_KEY)).toBeTruthy();
|
||||
|
||||
store.clear();
|
||||
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
||||
expect(sessionStorage.getItem(FULL_SESSION_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('rehydrates authenticated session from full session storage', () => {
|
||||
const session = createSession();
|
||||
store.setSession(session);
|
||||
|
||||
const rehydrated = createStore();
|
||||
expect(rehydrated.status()).toBe('authenticated');
|
||||
expect(rehydrated.isAuthenticated()).toBeTrue();
|
||||
expect(rehydrated.subjectHint()).toBe('user-123');
|
||||
expect(rehydrated.session()?.tokens.accessToken).toBe('token-abc');
|
||||
});
|
||||
|
||||
it('drops expired persisted full session and keeps unauthenticated state', () => {
|
||||
const expired = createSession(Date.now() - 5_000);
|
||||
sessionStorage.setItem(FULL_SESSION_STORAGE_KEY, JSON.stringify(expired));
|
||||
sessionStorage.setItem(
|
||||
SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
subject: expired.identity.subject,
|
||||
expiresAtEpochMs: expired.tokens.expiresAtEpochMs,
|
||||
issuedAtEpochMs: expired.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: expired.dpopKeyThumbprint,
|
||||
tenantId: expired.tenantId,
|
||||
})
|
||||
);
|
||||
|
||||
const rehydrated = createStore();
|
||||
expect(rehydrated.isAuthenticated()).toBeFalse();
|
||||
expect(rehydrated.session()).toBeNull();
|
||||
expect(rehydrated.subjectHint()).toBe('user-123');
|
||||
expect(sessionStorage.getItem(FULL_SESSION_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable, computed, signal } from '@angular/core';
|
||||
import {
|
||||
AuthSession,
|
||||
AuthStatus,
|
||||
FULL_SESSION_STORAGE_KEY,
|
||||
PersistedSessionMetadata,
|
||||
SESSION_STORAGE_KEY,
|
||||
} from './auth-session.model';
|
||||
@@ -11,10 +12,16 @@ import {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthSessionStore {
|
||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
|
||||
private readonly persistedSignal =
|
||||
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
|
||||
private readonly restoredSession = this.readPersistedSession();
|
||||
private readonly sessionSignal = signal<AuthSession | null>(
|
||||
this.restoredSession
|
||||
);
|
||||
private readonly statusSignal = signal<AuthStatus>(
|
||||
this.restoredSession ? 'authenticated' : 'unauthenticated'
|
||||
);
|
||||
private readonly persistedSignal = signal<PersistedSessionMetadata | null>(
|
||||
this.readPersistedMetadata(this.restoredSession)
|
||||
);
|
||||
|
||||
readonly session = computed(() => this.sessionSignal());
|
||||
readonly status = computed(() => this.statusSignal());
|
||||
@@ -52,19 +59,15 @@ export class AuthSessionStore {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
this.clearPersistedSession();
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusSignal.set('authenticated');
|
||||
const metadata: PersistedSessionMetadata = {
|
||||
subject: session.identity.subject,
|
||||
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
|
||||
issuedAtEpochMs: session.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: session.dpopKeyThumbprint,
|
||||
tenantId: session.tenantId,
|
||||
};
|
||||
const metadata = this.toMetadata(session);
|
||||
this.persistedSignal.set(metadata);
|
||||
this.persistMetadata(metadata);
|
||||
this.persistSession(session);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
@@ -72,9 +75,12 @@ export class AuthSessionStore {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
this.clearPersistedSession();
|
||||
}
|
||||
|
||||
private readPersistedMetadata(): PersistedSessionMetadata | null {
|
||||
private readPersistedMetadata(
|
||||
restoredSession: AuthSession | null
|
||||
): PersistedSessionMetadata | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
@@ -82,7 +88,12 @@ export class AuthSessionStore {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
if (!restoredSession) {
|
||||
return null;
|
||||
}
|
||||
const metadata = this.toMetadata(restoredSession);
|
||||
this.persistMetadata(metadata);
|
||||
return metadata;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
|
||||
if (
|
||||
@@ -91,7 +102,8 @@ export class AuthSessionStore {
|
||||
typeof parsed.issuedAtEpochMs !== 'number' ||
|
||||
typeof parsed.dpopKeyThumbprint !== 'string'
|
||||
) {
|
||||
return null;
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
return restoredSession ? this.toMetadata(restoredSession) : null;
|
||||
}
|
||||
const tenantId =
|
||||
typeof parsed.tenantId === 'string'
|
||||
@@ -105,8 +117,84 @@ export class AuthSessionStore {
|
||||
tenantId,
|
||||
};
|
||||
} catch {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
return restoredSession ? this.toMetadata(restoredSession) : null;
|
||||
}
|
||||
}
|
||||
|
||||
private readPersistedSession(): AuthSession | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(FULL_SESSION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as AuthSession;
|
||||
if (!this.isValidSession(parsed)) {
|
||||
sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.tokens.expiresAtEpochMs <= Date.now()) {
|
||||
sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private isValidSession(session: AuthSession | null): session is AuthSession {
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokens = session.tokens;
|
||||
const identity = session.identity;
|
||||
if (
|
||||
!tokens ||
|
||||
typeof tokens.accessToken !== 'string' ||
|
||||
typeof tokens.expiresAtEpochMs !== 'number' ||
|
||||
typeof tokens.scope !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!identity ||
|
||||
typeof identity.subject !== 'string' ||
|
||||
!Array.isArray(identity.roles)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof session.dpopKeyThumbprint !== 'string' ||
|
||||
typeof session.issuedAtEpochMs !== 'number' ||
|
||||
!Array.isArray(session.scopes) ||
|
||||
!Array.isArray(session.audiences)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private toMetadata(session: AuthSession): PersistedSessionMetadata {
|
||||
return {
|
||||
subject: session.identity.subject,
|
||||
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
|
||||
issuedAtEpochMs: session.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: session.dpopKeyThumbprint,
|
||||
tenantId: session.tenantId,
|
||||
};
|
||||
}
|
||||
|
||||
private persistMetadata(metadata: PersistedSessionMetadata): void {
|
||||
@@ -116,6 +204,13 @@ export class AuthSessionStore {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
private persistSession(session: AuthSession): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(FULL_SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
private clearPersistedMetadata(): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
@@ -123,6 +218,13 @@ export class AuthSessionStore {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
private clearPersistedSession(): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantId();
|
||||
}
|
||||
|
||||
@@ -588,7 +588,7 @@ export class ImpactPreviewComponent implements OnInit {
|
||||
setTimeout(() => {
|
||||
this.applying.set(false);
|
||||
// Navigate back to trust weights
|
||||
window.location.href = '/admin/policy/governance/trust-weights';
|
||||
window.location.href = '/policy/governance/trust-weights';
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ describe('PolicyAuditLogComponent', () => {
|
||||
component.viewDiff(entry);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(
|
||||
['/admin/policy/simulation/diff', 'policy-pack-001'],
|
||||
['/policy/simulation/diff', 'policy-pack-001'],
|
||||
{ queryParams: { from: 1, to: 2 } }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -628,7 +628,7 @@ export class PolicyAuditLogComponent implements OnInit {
|
||||
|
||||
viewDiff(entry: PolicyAuditEntry): void {
|
||||
if (entry.diffId && entry.policyVersion) {
|
||||
this.router.navigate(['/admin/policy/simulation/diff', entry.policyPackId], {
|
||||
this.router.navigate(['/policy/simulation/diff', entry.policyPackId], {
|
||||
queryParams: {
|
||||
from: entry.policyVersion - 1,
|
||||
to: entry.policyVersion,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file policy-simulation.routes.ts
|
||||
* @sprint SPRINT_20251229_021b_FE
|
||||
* @description Routes for Policy Simulation Studio at /admin/policy/simulation
|
||||
* @description Routes for Policy Simulation Studio at /policy/simulation
|
||||
*/
|
||||
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
@@ -228,12 +228,12 @@ describe('SimulationDashboardComponent', () => {
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should navigate to shadow on viewResults', fakeAsync(() => {
|
||||
it('should navigate to history on viewResults', fakeAsync(() => {
|
||||
spyOn(router, 'navigate');
|
||||
|
||||
component['navigateToShadow']();
|
||||
component['navigateToHistory']();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/admin/policy/simulation/shadow']);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/policy/simulation/history']);
|
||||
}));
|
||||
|
||||
it('should navigate to promotion on navigateToPromotion', fakeAsync(() => {
|
||||
@@ -241,7 +241,7 @@ describe('SimulationDashboardComponent', () => {
|
||||
|
||||
component['navigateToPromotion']();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/admin/policy/simulation/promotion']);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/policy/simulation/promotion']);
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
|
||||
[showActions]="true"
|
||||
(enable)="enableShadowMode()"
|
||||
(disable)="disableShadowMode()"
|
||||
(viewResults)="navigateToShadow()"
|
||||
(viewResults)="navigateToHistory()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -618,11 +618,11 @@ export class SimulationDashboardComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
protected navigateToShadow(): void {
|
||||
this.router.navigate(['/admin/policy/simulation/shadow']);
|
||||
protected navigateToHistory(): void {
|
||||
this.router.navigate(['/policy/simulation/history']);
|
||||
}
|
||||
|
||||
protected navigateToPromotion(): void {
|
||||
this.router.navigate(['/admin/policy/simulation/promotion']);
|
||||
this.router.navigate(['/policy/simulation/promotion']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('SimulationHistoryComponent', () => {
|
||||
component.viewSimulation('sim-001');
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
['/admin/policy/simulation/console'],
|
||||
['/policy/simulation/console'],
|
||||
{ queryParams: { simulationId: 'sim-001' } }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1170,7 +1170,7 @@ export class SimulationHistoryComponent implements OnInit {
|
||||
}
|
||||
|
||||
viewSimulation(simulationId: string): void {
|
||||
this.router.navigate(['/admin/policy/simulation/console'], {
|
||||
this.router.navigate(['/policy/simulation/console'], {
|
||||
queryParams: { simulationId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1088,8 +1088,6 @@ export class PolicyStudioComponent implements OnInit {
|
||||
}
|
||||
|
||||
viewProfile(profile: RiskProfileSummary): void {
|
||||
this.store.loadProfile(profile.profileId, { tenantId: this.tenantId });
|
||||
this.store.loadProfileVersions(profile.profileId, { tenantId: this.tenantId });
|
||||
this.router.navigate(['/policy/governance/profiles', profile.profileId]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3063,6 +3063,14 @@ export class StepContentComponent {
|
||||
readonly newRegistryProvider = signal<string | null>(null);
|
||||
readonly newScmProvider = signal<string | null>(null);
|
||||
readonly newNotifyProvider = signal<string | null>(null);
|
||||
private legacyMirrorDefaultsSanitized = false;
|
||||
|
||||
private static readonly LEGACY_MIRROR_ENDPOINT_DEFAULTS = new Set([
|
||||
'https://mirror.stella-ops.org/feeds',
|
||||
'https://mirror.stella-ops.org/feeds/',
|
||||
'https://mirrors.stella-ops.org/feeds',
|
||||
'https://mirrors.stella-ops.org/feeds/',
|
||||
]);
|
||||
|
||||
/** Sensible defaults for local/development setup. */
|
||||
private static readonly LOCAL_DEFAULTS: Record<string, Record<string, string>> = {
|
||||
@@ -3128,6 +3136,21 @@ export class StepContentComponent {
|
||||
if (sourceMode && !this.sourceFeedMode()) {
|
||||
this.sourceFeedMode.set(sourceMode);
|
||||
}
|
||||
|
||||
const mirrorUrlRaw = config['sources.mirror.url'];
|
||||
const mirrorUrl = typeof mirrorUrlRaw === 'string'
|
||||
? mirrorUrlRaw.trim().toLowerCase()
|
||||
: '';
|
||||
if (
|
||||
!this.legacyMirrorDefaultsSanitized &&
|
||||
StepContentComponent.LEGACY_MIRROR_ENDPOINT_DEFAULTS.has(mirrorUrl)
|
||||
) {
|
||||
this.legacyMirrorDefaultsSanitized = true;
|
||||
this.configChange.emit({ key: 'sources.mirror.url', value: '' });
|
||||
this.configChange.emit({ key: 'sources.mirror.apiKey', value: '' });
|
||||
this.sourceFeedMode.set('custom');
|
||||
this.configChange.emit({ key: 'sources.mode', value: 'custom' });
|
||||
}
|
||||
});
|
||||
|
||||
// Source feed mode: 'mirror' (Stella Ops pre-aggregated) or 'custom' (individual feeds)
|
||||
|
||||
@@ -718,9 +718,12 @@ export class VexStatementSearchComponent implements OnInit {
|
||||
|
||||
try {
|
||||
const result = await firstValueFrom(this.vexHubApi.searchStatements(params));
|
||||
this.statements.set(result.items);
|
||||
this.total.set(result.total);
|
||||
const items = Array.isArray(result?.items) ? result.items : [];
|
||||
this.statements.set(items);
|
||||
this.total.set(typeof result?.total === 'number' ? result.total : items.length);
|
||||
} catch (err) {
|
||||
this.statements.set([]);
|
||||
this.total.set(0);
|
||||
this.error.set(err instanceof Error ? err.message : 'Search failed');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
|
||||
@@ -9,12 +9,20 @@ import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnDestroy,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import { AppConfigService } from '../../core/config/app-config.service';
|
||||
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__stellaWelcomeSignIn?: (() => void) | null;
|
||||
__stellaWelcomePendingSignIn?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-welcome-page',
|
||||
imports: [],
|
||||
@@ -81,8 +89,8 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button type="button" class="cta" (click)="signIn()">
|
||||
<span class="cta__label">Sign In</span>
|
||||
<button type="button" class="cta" [disabled]="signingIn()" (click)="signIn()">
|
||||
<span class="cta__label">{{ !interactionReady() ? 'Preparing Sign-In...' : (signingIn() ? 'Signing In...' : 'Sign In') }}</span>
|
||||
<svg class="cta__arrow" viewBox="0 0 24 24" width="16" height="16"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -659,10 +667,16 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class WelcomePageComponent {
|
||||
export class WelcomePageComponent implements OnDestroy {
|
||||
private readonly configService = inject(AppConfigService);
|
||||
private readonly authService = inject(AuthorityAuthService);
|
||||
private readonly globalSignInTrigger = () => {
|
||||
void this.signIn();
|
||||
};
|
||||
readonly authNotice = signal<string | null>(null);
|
||||
readonly signingIn = signal(false);
|
||||
readonly interactionReady = signal(false);
|
||||
readonly pendingSignIn = signal(false);
|
||||
|
||||
readonly config = computed(() => this.configService.config);
|
||||
readonly title = computed(
|
||||
@@ -683,7 +697,49 @@ export class WelcomePageComponent {
|
||||
return secure.toString();
|
||||
});
|
||||
|
||||
signIn(): void {
|
||||
constructor() {
|
||||
// Ensure the primary action is wired as soon as browser bootstrap begins.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__stellaWelcomeSignIn = this.globalSignInTrigger;
|
||||
window.setTimeout(() => {
|
||||
this.interactionReady.set(true);
|
||||
if (this.pendingSignIn() || window.__stellaWelcomePendingSignIn) {
|
||||
this.pendingSignIn.set(false);
|
||||
window.__stellaWelcomePendingSignIn = false;
|
||||
void this.signIn();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
window.__stellaWelcomeSignIn === this.globalSignInTrigger
|
||||
) {
|
||||
window.__stellaWelcomeSignIn = null;
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(): Promise<void> {
|
||||
if (this.signingIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.interactionReady()) {
|
||||
this.pendingSignIn.set(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__stellaWelcomePendingSignIn = true;
|
||||
}
|
||||
this.authNotice.set('Preparing secure sign-in...');
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingSignIn.set(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__stellaWelcomePendingSignIn = false;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.location.protocol === 'http:') {
|
||||
const secureUrl = this.secureUrl();
|
||||
if (secureUrl) {
|
||||
@@ -696,7 +752,35 @@ export class WelcomePageComponent {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.signingIn.set(true);
|
||||
this.authNotice.set(null);
|
||||
void this.authService.beginLogin('/');
|
||||
|
||||
try {
|
||||
if (!this.configService.isConfigured()) {
|
||||
await this.configService.load();
|
||||
}
|
||||
|
||||
if (!this.configService.isConfigured()) {
|
||||
this.authNotice.set('Sign-in configuration is still loading. Please try again in a moment.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.authService.beginLogin('/');
|
||||
|
||||
// First click can occasionally race with early-runtime auth bootstrap;
|
||||
// retry once if we are still on the welcome page after a short delay.
|
||||
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/welcome')) {
|
||||
window.setTimeout(() => {
|
||||
if (window.location.pathname.startsWith('/welcome')) {
|
||||
void this.authService.beginLogin('/');
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
} catch {
|
||||
this.authNotice.set('Unable to start sign-in. Please retry.');
|
||||
} finally {
|
||||
this.signingIn.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,43 @@
|
||||
<div class="stella-splash__bar-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>document.getElementById('stella-splash').dataset.ts=Date.now();</script>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
<script>document.getElementById('stella-splash').dataset.ts=Date.now();</script>
|
||||
<script>
|
||||
(function () {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__stellaWelcomePendingSignIn = false;
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
function (event) {
|
||||
if (!window.location.pathname.startsWith('/welcome')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var button = target.closest('button.cta');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.__stellaWelcomeSignIn === 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
window.__stellaWelcomePendingSignIn = true;
|
||||
},
|
||||
true
|
||||
);
|
||||
})();
|
||||
</script>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user