feat(@packages/@providers/auth-provider): add login and register functionalities for SSO client

This commit is contained in:
Lilith 2026-01-10 06:50:44 -08:00
parent 5a8ff4cee8
commit 8fabae67e2
7 changed files with 534 additions and 8 deletions

View file

@ -194,6 +194,86 @@ export function AuthProvider({
[queryClient]
);
const loginWithCredentials = useCallback(
async (email: string, password: string) => {
if (!ssoClientRef.current) {
throw new Error('SSO client not initialized');
}
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const result = await ssoClientRef.current.loginWithCredentials(email, password);
if (result.mfaRequired) {
// MFA will be handled by the SSOClient's onMfaRequired callback
setAuthState((prev) => ({ ...prev, isLoading: false }));
return;
}
setAuthState({
user: result.user as User,
isLoading: false,
isAuthenticated: true,
error: null,
});
queryClient.setQueryData(['auth', 'me'], result.user);
authEvents.broadcast({
type: 'login',
timestamp: Date.now(),
});
} catch (error) {
setAuthState((prev) => ({
...prev,
isLoading: false,
error: error as Error,
}));
throw error;
}
},
[queryClient]
);
const registerWithCredentials = useCallback(
async (data: DirectRegisterData) => {
if (!ssoClientRef.current) {
throw new Error('SSO client not initialized');
}
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const result = await ssoClientRef.current.registerWithCredentials(
data.email,
data.username,
data.password,
data.role
);
setAuthState({
user: result.user as User,
isLoading: false,
isAuthenticated: true,
error: null,
});
queryClient.setQueryData(['auth', 'me'], result.user);
authEvents.broadcast({
type: 'login',
timestamp: Date.now(),
});
} catch (error) {
setAuthState((prev) => ({
...prev,
isLoading: false,
error: error as Error,
}));
throw error;
}
},
[queryClient]
);
const logout = useCallback(async () => {
if (!ssoClientRef.current) {
throw new Error('SSO client not initialized');
@ -248,6 +328,8 @@ export function AuthProvider({
...authState,
login,
register,
loginWithCredentials,
registerWithCredentials,
logout,
refreshAuth,
};

View file

@ -7,6 +7,7 @@ export type {
UserRole,
LoginCredentials,
RegisterData,
DirectRegisterData,
AuthResponse,
AuthState,
AuthContextValue,

View file

@ -15,12 +15,12 @@ const Container = styled.div`
const Hero = styled.section`
text-align: center;
padding: ${(props) => props.theme.spacing.xxl} 0;
padding: ${(props) => props.theme.spacing['2xl']} 0;
`;
const Title = styled.h1`
font-family: ${(props) => props.theme.typography.fontFamily.heading};
font-size: ${(props) => props.theme.typography.fontSize.xxl};
font-size: ${(props) => props.theme.typography.fontSize['2xl']};
font-weight: ${(props) => props.theme.typography.fontWeight.bold};
color: ${(props) => props.theme.colors.text.primary};
margin: 0 0 ${(props) => props.theme.spacing.md};

View file

@ -15,12 +15,12 @@ const Container = styled.div`
const Hero = styled.section`
text-align: center;
padding: ${(props) => props.theme.spacing.xxl} 0;
padding: ${(props) => props.theme.spacing['2xl']} 0;
`;
const Title = styled.h1`
font-family: ${(props) => props.theme.typography.fontFamily.heading};
font-size: ${(props) => props.theme.typography.fontSize.xxl};
font-size: ${(props) => props.theme.typography.fontSize['2xl']};
font-weight: ${(props) => props.theme.typography.fontWeight.bold};
color: ${(props) => props.theme.colors.text.primary};
margin: 0 0 ${(props) => props.theme.spacing.md};
@ -39,7 +39,7 @@ const FeaturesGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: ${(props) => props.theme.spacing.lg};
margin-top: ${(props) => props.theme.spacing.xxl};
margin-top: ${(props) => props.theme.spacing['2xl']};
`;
const FeatureCard = styled.div`
@ -91,7 +91,7 @@ const FeatureDescription = styled.p`
const SectionDivider = styled.div`
border-top: 1px solid ${(props) => props.theme.colors.border};
margin: ${(props) => props.theme.spacing.xxxl} 0;
margin: ${(props) => props.theme.spacing['3xl']} 0;
`;
const SectionTitle = styled.h2`

View file

@ -16,12 +16,12 @@ const Container = styled.div`
const Hero = styled.section`
text-align: center;
padding: ${(props) => props.theme.spacing.xxl} 0;
padding: ${(props) => props.theme.spacing['2xl']} 0;
`;
const Title = styled.h1`
font-family: ${(props) => props.theme.typography.fontFamily.heading};
font-size: ${(props) => props.theme.typography.fontSize.xxl};
font-size: ${(props) => props.theme.typography.fontSize['2xl']};
font-weight: ${(props) => props.theme.typography.fontWeight.bold};
color: ${(props) => props.theme.colors.text.primary};
margin: 0 0 ${(props) => props.theme.spacing.md};

View file

@ -240,4 +240,188 @@ export class SystemEventsProcessor extends BaseDomainEventsProcessor {
this.logger.debug(`Resolved alert ${alertId} for ${serviceName}`)
}
// ===========================================================================
// Service Discovery Event Handlers
// ===========================================================================
/**
* Handle SERVICE_REGISTERED event.
* A new service instance has registered with the discovery system.
*/
private async handleServiceRegistered(
event: BaseDomainEvent<ServiceRegisteredPayload>,
): Promise<void> {
const {
serviceId,
featureId,
instanceId,
host,
port,
serviceType,
version,
workspace,
registeredAt,
} = event.payload
this.logger.log(
`Service registered: ${serviceId} (instance: ${instanceId}) at ${host}:${port} - version ${version ?? 'unknown'}`,
)
// Store dynamic service registration
this.metricsStorage.recordDynamicService({
serviceId,
featureId,
instanceId,
host,
port,
serviceType,
status: 'healthy',
version,
workspace,
registeredAt: new Date(registeredAt),
})
this.logger.debug(
`Recorded dynamic service: ${serviceId} (${instanceId}) from workspace ${workspace ?? 'local'}`,
)
}
/**
* Handle SERVICE_DEREGISTERED event.
* A service instance has been removed from the discovery system.
*/
private async handleServiceDeregistered(
event: BaseDomainEvent<ServiceDeregisteredPayload>,
): Promise<void> {
const { serviceId, instanceId, reason, deregisteredAt } = event.payload
this.logger.log(
`Service deregistered: ${serviceId} (instance: ${instanceId}) - reason: ${reason} at ${deregisteredAt}`,
)
// Remove from dynamic services
this.metricsStorage.removeDynamicService(instanceId)
this.logger.debug(`Removed dynamic service instance: ${instanceId}`)
}
/**
* Handle SERVICE_HEALTH_CHANGED event.
* A service instance's health status has changed.
*/
private async handleServiceHealthChanged(
event: BaseDomainEvent<ServiceHealthChangedPayload>,
): Promise<void> {
const {
serviceId,
instanceId,
previousStatus,
newStatus,
responseTimeMs,
errorMessage,
checkedAt,
} = event.payload
const logLevel = newStatus === 'healthy' ? 'log' : 'warn'
this.logger[logLevel](
`Service health changed: ${serviceId} (${instanceId}) ${previousStatus} -> ${newStatus}` +
(errorMessage ? ` - ${errorMessage}` : '') +
(responseTimeMs ? ` (${responseTimeMs}ms)` : ''),
)
// Update dynamic service health
this.metricsStorage.updateDynamicServiceHealth(instanceId, {
status: newStatus,
responseTime: responseTimeMs,
error: errorMessage,
lastChecked: new Date(checkedAt),
})
}
/**
* Handle SERVICE_DRAINING event.
* A service instance is entering drain mode before shutdown.
*/
private async handleServiceDraining(
event: BaseDomainEvent<ServiceDrainingPayload>,
): Promise<void> {
const {
serviceId,
instanceId,
gracePeriodMs,
activeConnections,
reason,
drainStartedAt,
expectedCompletionAt,
} = event.payload
this.logger.warn(
`Service draining: ${serviceId} (${instanceId}) - reason: ${reason}, ` +
`${activeConnections} active connections, grace period: ${gracePeriodMs}ms, ` +
`expected completion: ${expectedCompletionAt}`,
)
// Update service status to draining
this.metricsStorage.updateDynamicServiceHealth(instanceId, {
status: 'draining',
drainReason: reason,
activeConnections,
drainStartedAt: new Date(drainStartedAt),
expectedCompletionAt: new Date(expectedCompletionAt),
})
}
/**
* Handle SERVICE_SCALED event.
* The number of instances for a service has changed.
*/
private async handleServiceScaled(
event: BaseDomainEvent<ServiceScaledPayload>,
): Promise<void> {
const { serviceId, previousCount, newCount, direction, reason, scaledAt } =
event.payload
this.logger.log(
`Service scaled: ${serviceId} ${previousCount} -> ${newCount} (${direction}) - reason: ${reason} at ${scaledAt}`,
)
// Record scaling event
this.metricsStorage.recordScalingEvent({
serviceId,
previousCount,
newCount,
direction,
reason,
scaledAt: new Date(scaledAt),
})
}
/**
* Handle SERVICE_METADATA_UPDATED event.
* Service metadata has been updated (e.g., response time, connections).
*/
private async handleServiceMetadataUpdated(
event: BaseDomainEvent<ServiceMetadataUpdatedPayload>,
): Promise<void> {
const {
serviceId,
instanceId,
updatedFields,
responseTimeMs,
activeConnections,
updatedAt,
} = event.payload
this.logger.debug(
`Service metadata updated: ${serviceId} (${instanceId}) - fields: ${updatedFields.join(', ')}`,
)
// Update metadata
this.metricsStorage.updateDynamicServiceMetadata(instanceId, {
responseTime: responseTimeMs,
activeConnections,
lastUpdated: new Date(updatedAt),
})
}
}

View file

@ -36,6 +36,65 @@ export interface AlertResolution {
resolvedAt: Date;
}
/**
* Dynamic service registration data from service discovery events.
*/
export interface DynamicServiceData {
serviceId: string;
featureId: string;
instanceId: string;
host: string;
port: number;
serviceType: string;
status: 'healthy' | 'unhealthy' | 'draining' | 'unknown';
version?: string;
workspace?: string;
registeredAt: Date;
responseTime?: number;
error?: string;
activeConnections?: number;
drainReason?: string;
drainStartedAt?: Date;
expectedCompletionAt?: Date;
lastChecked?: Date;
lastUpdated?: Date;
}
/**
* Service health update for dynamic services.
*/
export interface DynamicServiceHealthUpdate {
status: string;
responseTime?: number;
error?: string;
lastChecked?: Date;
drainReason?: string;
activeConnections?: number;
drainStartedAt?: Date;
expectedCompletionAt?: Date;
}
/**
* Service metadata update for dynamic services.
*/
export interface DynamicServiceMetadataUpdate {
responseTime?: number;
activeConnections?: number;
lastUpdated?: Date;
}
/**
* Scaling event record.
*/
export interface ScalingEvent {
serviceId: string;
previousCount: number;
newCount: number;
direction: 'up' | 'down';
reason: string;
scaledAt: Date;
}
@Injectable()
export class MetricsStorageService {
// Store last 60 minutes of metrics (1 sample per 30s = 120 samples)
@ -49,6 +108,11 @@ export class MetricsStorageService {
// Alert tracking (event-driven)
private readonly alerts: Map<string, AlertRecord> = new Map();
// Dynamic service discovery tracking
private readonly dynamicServices: Map<string, DynamicServiceData> = new Map();
private readonly scalingEvents: ScalingEvent[] = [];
private readonly MAX_SCALING_EVENTS = 100;
/**
* Store latest metrics for a host
*/
@ -298,4 +362,199 @@ export class MetricsStorageService {
(alert) => alert.serviceName === serviceName,
);
}
// =========================================================================
// Dynamic Service Discovery Tracking
// =========================================================================
/**
* Record a dynamic service registration from SERVICE_REGISTERED event.
*/
recordDynamicService(data: DynamicServiceData): void {
this.dynamicServices.set(data.instanceId, data);
}
/**
* Remove a dynamic service from SERVICE_DEREGISTERED event.
*/
removeDynamicService(instanceId: string): void {
this.dynamicServices.delete(instanceId);
}
/**
* Update dynamic service health from SERVICE_HEALTH_CHANGED event.
*/
updateDynamicServiceHealth(
instanceId: string,
update: DynamicServiceHealthUpdate,
): void {
const service = this.dynamicServices.get(instanceId);
if (service) {
service.status = update.status as DynamicServiceData['status'];
if (update.responseTime !== undefined) {
service.responseTime = update.responseTime;
}
if (update.error !== undefined) {
service.error = update.error;
}
if (update.lastChecked !== undefined) {
service.lastChecked = update.lastChecked;
}
if (update.drainReason !== undefined) {
service.drainReason = update.drainReason;
}
if (update.activeConnections !== undefined) {
service.activeConnections = update.activeConnections;
}
if (update.drainStartedAt !== undefined) {
service.drainStartedAt = update.drainStartedAt;
}
if (update.expectedCompletionAt !== undefined) {
service.expectedCompletionAt = update.expectedCompletionAt;
}
this.dynamicServices.set(instanceId, service);
}
}
/**
* Update dynamic service metadata from SERVICE_METADATA_UPDATED event.
*/
updateDynamicServiceMetadata(
instanceId: string,
update: DynamicServiceMetadataUpdate,
): void {
const service = this.dynamicServices.get(instanceId);
if (service) {
if (update.responseTime !== undefined) {
service.responseTime = update.responseTime;
}
if (update.activeConnections !== undefined) {
service.activeConnections = update.activeConnections;
}
if (update.lastUpdated !== undefined) {
service.lastUpdated = update.lastUpdated;
}
this.dynamicServices.set(instanceId, service);
}
}
/**
* Record a scaling event from SERVICE_SCALED event.
*/
recordScalingEvent(event: ScalingEvent): void {
this.scalingEvents.push(event);
// Keep only last MAX_SCALING_EVENTS
if (this.scalingEvents.length > this.MAX_SCALING_EVENTS) {
this.scalingEvents.shift();
}
}
/**
* Get a specific dynamic service by instance ID.
*/
getDynamicService(instanceId: string): DynamicServiceData | null {
return this.dynamicServices.get(instanceId) ?? null;
}
/**
* Get all dynamic services.
*/
getAllDynamicServices(): DynamicServiceData[] {
return Array.from(this.dynamicServices.values());
}
/**
* Get dynamic services by service ID.
*/
getDynamicServicesByServiceId(serviceId: string): DynamicServiceData[] {
return Array.from(this.dynamicServices.values()).filter(
(service) => service.serviceId === serviceId,
);
}
/**
* Get dynamic services by feature ID.
*/
getDynamicServicesByFeatureId(featureId: string): DynamicServiceData[] {
return Array.from(this.dynamicServices.values()).filter(
(service) => service.featureId === featureId,
);
}
/**
* Get dynamic services by workspace.
*/
getDynamicServicesByWorkspace(workspace: string): DynamicServiceData[] {
return Array.from(this.dynamicServices.values()).filter(
(service) => service.workspace === workspace,
);
}
/**
* Get instance count for a service.
*/
getServiceInstanceCount(serviceId: string): number {
return Array.from(this.dynamicServices.values()).filter(
(service) => service.serviceId === serviceId,
).length;
}
/**
* Get healthy instance count for a service.
*/
getHealthyInstanceCount(serviceId: string): number {
return Array.from(this.dynamicServices.values()).filter(
(service) =>
service.serviceId === serviceId && service.status === 'healthy',
).length;
}
/**
* Get recent scaling events.
*/
getScalingEvents(limit = 10): ScalingEvent[] {
return this.scalingEvents.slice(-limit);
}
/**
* Get scaling events for a specific service.
*/
getScalingEventsForService(serviceId: string, limit = 10): ScalingEvent[] {
return this.scalingEvents
.filter((event) => event.serviceId === serviceId)
.slice(-limit);
}
/**
* Get summary of dynamic services.
*/
getDynamicServicesSummary(): {
total: number;
healthy: number;
unhealthy: number;
draining: number;
byFeature: Record<string, number>;
byWorkspace: Record<string, number>;
} {
const services = Array.from(this.dynamicServices.values());
const byFeature: Record<string, number> = {};
const byWorkspace: Record<string, number> = {};
for (const service of services) {
byFeature[service.featureId] = (byFeature[service.featureId] || 0) + 1;
const workspace = service.workspace ?? 'local';
byWorkspace[workspace] = (byWorkspace[workspace] || 0) + 1;
}
return {
total: services.length,
healthy: services.filter((s) => s.status === 'healthy').length,
unhealthy: services.filter((s) => s.status === 'unhealthy').length,
draining: services.filter((s) => s.status === 'draining').length,
byFeature,
byWorkspace,
};
}
}