feat(auth): Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
import { adminGuard } from './core/guards/admin.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -7,6 +8,13 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/login/login.component').then((m) => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'oauth-callback',
|
||||
loadComponent: () =>
|
||||
import('./features/oauth-callback/oauth-callback.component').then(
|
||||
(m) => m.OAuthCallbackComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
@@ -86,6 +94,39 @@ export const routes: Routes = [
|
||||
(m) => m.SettingsComponent
|
||||
),
|
||||
},
|
||||
// Admin routes
|
||||
{
|
||||
path: 'admin',
|
||||
canActivate: [adminGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'auth',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/auth-providers/auth-providers.component').then(
|
||||
(m) => m.AuthProvidersComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/providers/new',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/auth-providers/provider-form.component').then(
|
||||
(m) => m.ProviderFormComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/providers/:id',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/auth-providers/provider-form.component').then(
|
||||
(m) => m.ProviderFormComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
27
ui/src/app/core/guards/admin.guard.ts
Normal file
27
ui/src/app/core/guards/admin.guard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, type CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const adminGuard: CanActivateFn = async () => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
// First check if authenticated
|
||||
if (!authService.isAuthenticated()) {
|
||||
// Try to refresh the token
|
||||
const refreshed = await authService.refreshAccessToken();
|
||||
if (!refreshed) {
|
||||
router.navigate(['/login']);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Then check if admin
|
||||
if (!authService.isAdmin()) {
|
||||
// Not an admin, redirect to dashboard
|
||||
router.navigate(['/dashboard']);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
141
ui/src/app/core/services/admin-auth.service.ts
Normal file
141
ui/src/app/core/services/admin-auth.service.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
// Types
|
||||
export type TAuthProviderType = 'oidc' | 'ldap';
|
||||
export type TAuthProviderStatus = 'active' | 'disabled' | 'testing';
|
||||
|
||||
export interface IOAuthConfig {
|
||||
clientId: string;
|
||||
clientSecretEncrypted: string;
|
||||
issuer: string;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
userInfoUrl?: string;
|
||||
scopes: string[];
|
||||
callbackUrl: string;
|
||||
}
|
||||
|
||||
export interface ILdapConfig {
|
||||
serverUrl: string;
|
||||
bindDn: string;
|
||||
bindPasswordEncrypted: string;
|
||||
baseDn: string;
|
||||
userSearchFilter: string;
|
||||
tlsEnabled: boolean;
|
||||
tlsCaCert?: string;
|
||||
}
|
||||
|
||||
export interface IAttributeMapping {
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
groups?: string;
|
||||
}
|
||||
|
||||
export interface IProvisioningSettings {
|
||||
jitEnabled: boolean;
|
||||
autoLinkByEmail: boolean;
|
||||
allowedEmailDomains?: string[];
|
||||
}
|
||||
|
||||
export interface IAuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
status: TAuthProviderStatus;
|
||||
priority: number;
|
||||
oauthConfig?: IOAuthConfig;
|
||||
ldapConfig?: ILdapConfig;
|
||||
attributeMapping: IAttributeMapping;
|
||||
provisioning: IProvisioningSettings;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdById: string;
|
||||
lastTestedAt?: string;
|
||||
lastTestResult?: 'success' | 'failure';
|
||||
lastTestError?: string;
|
||||
}
|
||||
|
||||
export interface IPlatformAuthSettings {
|
||||
localAuthEnabled: boolean;
|
||||
allowUserRegistration: boolean;
|
||||
sessionDurationMinutes: number;
|
||||
defaultProviderId?: string;
|
||||
}
|
||||
|
||||
export interface IPlatformSettings {
|
||||
id: string;
|
||||
auth: IPlatformAuthSettings;
|
||||
updatedAt: string;
|
||||
updatedById?: string;
|
||||
}
|
||||
|
||||
export interface IConnectionTestResult {
|
||||
success: boolean;
|
||||
latencyMs: number;
|
||||
serverInfo?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ICreateAuthProviderDto {
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
oauthConfig?: IOAuthConfig;
|
||||
ldapConfig?: ILdapConfig;
|
||||
attributeMapping?: IAttributeMapping;
|
||||
provisioning?: IProvisioningSettings;
|
||||
}
|
||||
|
||||
export interface IUpdateAuthProviderDto {
|
||||
displayName?: string;
|
||||
status?: TAuthProviderStatus;
|
||||
priority?: number;
|
||||
oauthConfig?: Partial<IOAuthConfig>;
|
||||
ldapConfig?: Partial<ILdapConfig>;
|
||||
attributeMapping?: Partial<IAttributeMapping>;
|
||||
provisioning?: Partial<IProvisioningSettings>;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminAuthService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Provider CRUD
|
||||
listProviders(): Observable<{ providers: IAuthProvider[] }> {
|
||||
return this.http.get<{ providers: IAuthProvider[] }>('/api/v1/admin/auth/providers');
|
||||
}
|
||||
|
||||
getProvider(id: string): Observable<IAuthProvider> {
|
||||
return this.http.get<IAuthProvider>(`/api/v1/admin/auth/providers/${id}`);
|
||||
}
|
||||
|
||||
createProvider(dto: ICreateAuthProviderDto): Observable<IAuthProvider> {
|
||||
return this.http.post<IAuthProvider>('/api/v1/admin/auth/providers', dto);
|
||||
}
|
||||
|
||||
updateProvider(id: string, dto: IUpdateAuthProviderDto): Observable<IAuthProvider> {
|
||||
return this.http.put<IAuthProvider>(`/api/v1/admin/auth/providers/${id}`, dto);
|
||||
}
|
||||
|
||||
deleteProvider(id: string): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(`/api/v1/admin/auth/providers/${id}`);
|
||||
}
|
||||
|
||||
testProvider(id: string): Observable<IConnectionTestResult> {
|
||||
return this.http.post<IConnectionTestResult>(`/api/v1/admin/auth/providers/${id}/test`, {});
|
||||
}
|
||||
|
||||
// Platform settings
|
||||
getSettings(): Observable<IPlatformSettings> {
|
||||
return this.http.get<IPlatformSettings>('/api/v1/admin/auth/settings');
|
||||
}
|
||||
|
||||
updateSettings(settings: Partial<{ auth: Partial<IPlatformAuthSettings> }>): Observable<IPlatformSettings> {
|
||||
return this.http.put<IPlatformSettings>('/api/v1/admin/auth/settings', settings);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,19 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback tokens from external providers
|
||||
*/
|
||||
handleOAuthCallback(accessToken: string, refreshToken: string, sessionId: string): void {
|
||||
this._accessToken.set(accessToken);
|
||||
this._refreshToken.set(refreshToken);
|
||||
this._sessionId.set(sessionId);
|
||||
this.saveToStorage();
|
||||
|
||||
// Fetch user info asynchronously
|
||||
this.fetchCurrentUser();
|
||||
}
|
||||
|
||||
private loadFromStorage(): void {
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import {
|
||||
AdminAuthService,
|
||||
type IAuthProvider,
|
||||
type IPlatformSettings,
|
||||
type TAuthProviderStatus,
|
||||
} from '../../../core/services/admin-auth.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-providers',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div class="section-header mb-2">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="section-label">Admin</span>
|
||||
</div>
|
||||
<h1 class="font-mono text-2xl font-bold text-foreground">Authentication Providers</h1>
|
||||
<p class="font-mono text-sm text-muted-foreground mt-1">Configure OAuth and LDAP authentication</p>
|
||||
</div>
|
||||
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Provider
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Platform Settings Card -->
|
||||
<div class="card mb-6">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Platform Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
@if (settings()) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-medium text-foreground">Local Authentication</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">Allow email/password login</p>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleLocalAuth()"
|
||||
[class]="settings()!.auth.localAuthEnabled ? 'badge-accent' : 'badge-secondary'"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{ settings()!.auth.localAuthEnabled ? 'Enabled' : 'Disabled' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-medium text-foreground">User Registration</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">Allow new account creation</p>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleRegistration()"
|
||||
[class]="settings()!.auth.allowUserRegistration ? 'badge-accent' : 'badge-secondary'"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{ settings()!.auth.allowUserRegistration ? 'Enabled' : 'Disabled' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-medium text-foreground">Session Duration</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">{{ formatDuration(settings()!.auth.sessionDurationMinutes) }}</p>
|
||||
</div>
|
||||
<button (click)="showSettingsModal.set(true)" class="btn-ghost btn-sm">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="animate-pulse flex space-x-4">
|
||||
<div class="flex-1 space-y-2 py-1">
|
||||
<div class="h-4 bg-muted"></div>
|
||||
<div class="h-4 bg-muted w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Providers List -->
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else if (providers().length === 0) {
|
||||
<div class="card card-content text-center py-12">
|
||||
<svg class="w-16 h-16 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 class="font-mono text-lg font-medium text-foreground mb-2">No providers configured</h3>
|
||||
<p class="font-mono text-sm text-muted-foreground mb-4">Add an OAuth or LDAP provider to enable single sign-on</p>
|
||||
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||
Add Provider
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-4">
|
||||
@for (provider of providers(); track provider.id) {
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 flex items-center justify-center flex-shrink-0" [class]="getProviderIconClass(provider.type)">
|
||||
@if (provider.type === 'oidc') {
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-mono font-semibold text-foreground">{{ provider.displayName }}</h3>
|
||||
<span [class]="getStatusBadgeClass(provider.status)">{{ provider.status }}</span>
|
||||
@if (settings()?.auth?.defaultProviderId === provider.id) {
|
||||
<span class="badge-primary">Default</span>
|
||||
}
|
||||
</div>
|
||||
<p class="font-mono text-sm text-muted-foreground">{{ provider.name }} · {{ provider.type.toUpperCase() }}</p>
|
||||
@if (provider.type === 'oidc' && provider.oauthConfig) {
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1 truncate">{{ provider.oauthConfig.issuer }}</p>
|
||||
}
|
||||
@if (provider.type === 'ldap' && provider.ldapConfig) {
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1 truncate">{{ provider.ldapConfig.serverUrl }}</p>
|
||||
}
|
||||
@if (provider.lastTestedAt) {
|
||||
<div class="flex items-center gap-2 mt-2 font-mono text-xs">
|
||||
@if (provider.lastTestResult === 'success') {
|
||||
<span class="text-accent flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Connection OK
|
||||
</span>
|
||||
} @else {
|
||||
<span class="text-destructive flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Connection Failed
|
||||
</span>
|
||||
}
|
||||
<span class="text-muted-foreground">
|
||||
tested {{ formatDate(provider.lastTestedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
(click)="testProvider(provider)"
|
||||
[disabled]="testing() === provider.id"
|
||||
class="btn-ghost btn-sm"
|
||||
>
|
||||
@if (testing() === provider.id) {
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
} @else {
|
||||
Test
|
||||
}
|
||||
</button>
|
||||
<button (click)="editProvider(provider)" class="btn-ghost btn-sm">Edit</button>
|
||||
<button (click)="confirmDelete(provider)" class="btn-ghost btn-sm text-destructive hover:text-destructive">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Create Modal -->
|
||||
@if (showCreateModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Select Provider Type</span>
|
||||
</div>
|
||||
<button (click)="showCreateModal.set(false)" class="btn-ghost btn-sm p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-content space-y-3">
|
||||
<button
|
||||
(click)="createProvider('oidc')"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 text-left transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 bg-primary/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-mono text-sm font-semibold text-foreground">OAuth / OIDC</h4>
|
||||
<p class="font-mono text-xs text-muted-foreground">Google, Azure AD, Okta, Auth0, etc.</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
(click)="createProvider('ldap')"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 text-left transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 bg-accent/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-mono text-sm font-semibold text-foreground">LDAP / Active Directory</h4>
|
||||
<p class="font-mono text-xs text-muted-foreground">Enterprise directory service</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@if (providerToDelete()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator bg-destructive"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Delete Provider</span>
|
||||
</div>
|
||||
<button (click)="providerToDelete.set(null)" class="btn-ghost btn-sm p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p class="font-mono text-sm text-foreground">
|
||||
Are you sure you want to delete <strong>{{ providerToDelete()!.displayName }}</strong>?
|
||||
</p>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-2">
|
||||
Users who signed in with this provider will no longer be able to authenticate through it.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end gap-3">
|
||||
<button (click)="providerToDelete.set(null)" class="btn-secondary btn-md">Cancel</button>
|
||||
<button (click)="deleteProvider()" [disabled]="deleting()" class="btn-destructive btn-md">
|
||||
@if (deleting()) {
|
||||
Deleting...
|
||||
} @else {
|
||||
Delete
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Settings Modal -->
|
||||
@if (showSettingsModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Platform Settings</span>
|
||||
</div>
|
||||
<button (click)="showSettingsModal.set(false)" class="btn-ghost btn-sm p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Session Duration (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
[value]="editingSettings.sessionDurationMinutes"
|
||||
(input)="editingSettings.sessionDurationMinutes = +($any($event.target).value)"
|
||||
class="input"
|
||||
min="60"
|
||||
max="43200"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">How long user sessions remain valid (60-43200 minutes)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Default Provider</label>
|
||||
<select
|
||||
[value]="editingSettings.defaultProviderId || ''"
|
||||
(change)="editingSettings.defaultProviderId = $any($event.target).value || undefined"
|
||||
class="input"
|
||||
>
|
||||
<option value="">None (show all options)</option>
|
||||
@for (provider of providers(); track provider.id) {
|
||||
@if (provider.status === 'active') {
|
||||
<option [value]="provider.id">{{ provider.displayName }}</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Automatically redirect to this provider on login</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end gap-3">
|
||||
<button (click)="showSettingsModal.set(false)" class="btn-secondary btn-md">Cancel</button>
|
||||
<button (click)="saveSettings()" [disabled]="savingSettings()" class="btn-primary btn-md">
|
||||
@if (savingSettings()) {
|
||||
Saving...
|
||||
} @else {
|
||||
Save
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AuthProvidersComponent implements OnInit {
|
||||
private adminAuthService = inject(AdminAuthService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
providers = signal<IAuthProvider[]>([]);
|
||||
settings = signal<IPlatformSettings | null>(null);
|
||||
loading = signal(true);
|
||||
testing = signal<string | null>(null);
|
||||
deleting = signal(false);
|
||||
savingSettings = signal(false);
|
||||
|
||||
showCreateModal = signal(false);
|
||||
showSettingsModal = signal(false);
|
||||
providerToDelete = signal<IAuthProvider | null>(null);
|
||||
selectedProviderForEdit = signal<IAuthProvider | null>(null);
|
||||
|
||||
editingSettings = {
|
||||
sessionDurationMinutes: 10080,
|
||||
defaultProviderId: undefined as string | undefined,
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const [providersRes, settingsRes] = await Promise.all([
|
||||
this.adminAuthService.listProviders().toPromise(),
|
||||
this.adminAuthService.getSettings().toPromise(),
|
||||
]);
|
||||
this.providers.set(providersRes?.providers || []);
|
||||
if (settingsRes) {
|
||||
this.settings.set(settingsRes);
|
||||
this.editingSettings = {
|
||||
sessionDurationMinutes: settingsRes.auth.sessionDurationMinutes,
|
||||
defaultProviderId: settingsRes.auth.defaultProviderId,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load authentication settings');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
createProvider(type: 'oidc' | 'ldap'): void {
|
||||
this.showCreateModal.set(false);
|
||||
// Navigate to provider form
|
||||
window.location.href = `/admin/auth/providers/new?type=${type}`;
|
||||
}
|
||||
|
||||
editProvider(provider: IAuthProvider): void {
|
||||
window.location.href = `/admin/auth/providers/${provider.id}`;
|
||||
}
|
||||
|
||||
async testProvider(provider: IAuthProvider): Promise<void> {
|
||||
this.testing.set(provider.id);
|
||||
try {
|
||||
const result = await this.adminAuthService.testProvider(provider.id).toPromise();
|
||||
if (result?.success) {
|
||||
this.toastService.success(`Connection successful (${result.latencyMs}ms)`);
|
||||
} else {
|
||||
this.toastService.error(result?.error || 'Connection failed');
|
||||
}
|
||||
// Reload to get updated test results
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to test provider');
|
||||
} finally {
|
||||
this.testing.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
confirmDelete(provider: IAuthProvider): void {
|
||||
this.providerToDelete.set(provider);
|
||||
}
|
||||
|
||||
async deleteProvider(): Promise<void> {
|
||||
const provider = this.providerToDelete();
|
||||
if (!provider) return;
|
||||
|
||||
this.deleting.set(true);
|
||||
try {
|
||||
await this.adminAuthService.deleteProvider(provider.id).toPromise();
|
||||
this.toastService.success('Provider deleted');
|
||||
this.providerToDelete.set(null);
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to delete provider');
|
||||
} finally {
|
||||
this.deleting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleLocalAuth(): Promise<void> {
|
||||
const current = this.settings();
|
||||
if (!current) return;
|
||||
|
||||
try {
|
||||
await this.adminAuthService.updateSettings({
|
||||
auth: { localAuthEnabled: !current.auth.localAuthEnabled },
|
||||
}).toPromise();
|
||||
this.toastService.success('Settings updated');
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to update settings');
|
||||
}
|
||||
}
|
||||
|
||||
async toggleRegistration(): Promise<void> {
|
||||
const current = this.settings();
|
||||
if (!current) return;
|
||||
|
||||
try {
|
||||
await this.adminAuthService.updateSettings({
|
||||
auth: { allowUserRegistration: !current.auth.allowUserRegistration },
|
||||
}).toPromise();
|
||||
this.toastService.success('Settings updated');
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to update settings');
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
this.savingSettings.set(true);
|
||||
try {
|
||||
await this.adminAuthService.updateSettings({
|
||||
auth: {
|
||||
sessionDurationMinutes: this.editingSettings.sessionDurationMinutes,
|
||||
defaultProviderId: this.editingSettings.defaultProviderId,
|
||||
},
|
||||
}).toPromise();
|
||||
this.toastService.success('Settings saved');
|
||||
this.showSettingsModal.set(false);
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to save settings');
|
||||
} finally {
|
||||
this.savingSettings.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
getProviderIconClass(type: string): string {
|
||||
return type === 'oidc' ? 'bg-primary/10 text-primary' : 'bg-accent/10 text-accent';
|
||||
}
|
||||
|
||||
getStatusBadgeClass(status: TAuthProviderStatus): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'badge-accent';
|
||||
case 'testing':
|
||||
return 'badge-warning';
|
||||
case 'disabled':
|
||||
return 'badge-secondary';
|
||||
default:
|
||||
return 'badge-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
formatDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} minutes`;
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
|
||||
return `${Math.round(minutes / 1440)} days`;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
AdminAuthService,
|
||||
type IAuthProvider,
|
||||
type ICreateAuthProviderDto,
|
||||
type IUpdateAuthProviderDto,
|
||||
type TAuthProviderType,
|
||||
type TAuthProviderStatus,
|
||||
} from '../../../core/services/admin-auth.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-provider-form',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<div class="section-header mb-2">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="section-label">Admin / Auth Providers</span>
|
||||
</div>
|
||||
<h1 class="font-mono text-2xl font-bold text-foreground">
|
||||
{{ isEditMode() ? 'Edit Provider' : 'New ' + (providerType() === 'oidc' ? 'OAuth/OIDC' : 'LDAP') + ' Provider' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else {
|
||||
<form (ngSubmit)="saveProvider()" class="space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Basic Information</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Name (identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.name"
|
||||
name="name"
|
||||
class="input"
|
||||
placeholder="google, azure-ad, corp-ldap"
|
||||
required
|
||||
[disabled]="isEditMode()"
|
||||
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase, alphanumeric with hyphens</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.displayName"
|
||||
name="displayName"
|
||||
class="input"
|
||||
placeholder="Google SSO, Corporate LDAP"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Shown on login page</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (isEditMode()) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Status</label>
|
||||
<select [(ngModel)]="form.status" name="status" class="input">
|
||||
<option value="active">Active</option>
|
||||
<option value="testing">Testing</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="form.priority"
|
||||
name="priority"
|
||||
class="input"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Higher = shown first (0-100)</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Config -->
|
||||
@if (providerType() === 'oidc') {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">OAuth / OIDC Configuration</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Issuer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.issuer"
|
||||
name="issuer"
|
||||
class="input"
|
||||
placeholder="https://accounts.google.com"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">OIDC discovery endpoint base URL</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.oauthConfig.clientId"
|
||||
name="clientId"
|
||||
class="input"
|
||||
placeholder="your-client-id"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="form.oauthConfig.clientSecretEncrypted"
|
||||
name="clientSecret"
|
||||
class="input"
|
||||
[placeholder]="isEditMode() ? '••••••••' : 'your-client-secret'"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||
@if (isEditMode()) {
|
||||
Leave empty to keep existing secret
|
||||
} @else {
|
||||
Will be encrypted at rest
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Scopes</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="scopesInput"
|
||||
name="scopes"
|
||||
class="input"
|
||||
placeholder="openid profile email"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Space-separated OAuth scopes</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Callback URL</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
[value]="getCallbackUrl()"
|
||||
class="input flex-1"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" (click)="copyCallbackUrl()" class="btn-secondary btn-md">Copy</button>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Add this to your OAuth provider's allowed redirect URIs</p>
|
||||
</div>
|
||||
|
||||
<!-- Advanced OAuth Settings -->
|
||||
<details class="group">
|
||||
<summary class="font-mono text-sm font-medium text-foreground cursor-pointer hover:text-primary">
|
||||
Advanced Settings
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4 pl-4 border-l border-border">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Authorization URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.authorizationUrl"
|
||||
name="authorizationUrl"
|
||||
class="input"
|
||||
placeholder="Override OIDC discovery"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Token URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.tokenUrl"
|
||||
name="tokenUrl"
|
||||
class="input"
|
||||
placeholder="Override OIDC discovery"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">User Info URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.userInfoUrl"
|
||||
name="userInfoUrl"
|
||||
class="input"
|
||||
placeholder="Override OIDC discovery"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- LDAP Config -->
|
||||
@if (providerType() === 'ldap') {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">LDAP Configuration</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Server URL</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.serverUrl"
|
||||
name="serverUrl"
|
||||
class="input"
|
||||
placeholder="ldap://ldap.example.com:389"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">LDAP or LDAPS protocol URL</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Bind DN</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.bindDn"
|
||||
name="bindDn"
|
||||
class="input"
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Bind Password</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="form.ldapConfig.bindPasswordEncrypted"
|
||||
name="bindPassword"
|
||||
class="input"
|
||||
[placeholder]="isEditMode() ? '••••••••' : 'your-bind-password'"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||
@if (isEditMode()) {
|
||||
Leave empty to keep existing password
|
||||
} @else {
|
||||
Will be encrypted at rest
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Base DN</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.baseDn"
|
||||
name="baseDn"
|
||||
class="input"
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Base DN for user searches</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">User Search Filter</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.userSearchFilter"
|
||||
name="userSearchFilter"
|
||||
class="input"
|
||||
[placeholder]="'(uid=' + '{{' + 'username' + '}}' + ')'"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Use double-brace username placeholder</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.ldapConfig.tlsEnabled"
|
||||
name="tlsEnabled"
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="font-mono text-sm text-foreground">Enable TLS/StartTLS</span>
|
||||
</label>
|
||||
</div>
|
||||
@if (form.ldapConfig.tlsEnabled) {
|
||||
<div>
|
||||
<label class="label block mb-1.5">CA Certificate (optional)</label>
|
||||
<textarea
|
||||
[(ngModel)]="form.ldapConfig.tlsCaCert"
|
||||
name="tlsCaCert"
|
||||
class="input min-h-[100px] font-mono text-xs"
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
></textarea>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">PEM-encoded CA certificate for self-signed servers</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Attribute Mapping -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Attribute Mapping</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<p class="font-mono text-xs text-muted-foreground">
|
||||
Map provider attributes to user fields. Use claim names for OAuth or attribute names for LDAP.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Email</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.email"
|
||||
name="mapEmail"
|
||||
class="input"
|
||||
placeholder="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.username"
|
||||
name="mapUsername"
|
||||
class="input"
|
||||
[placeholder]="providerType() === 'oidc' ? 'preferred_username' : 'uid'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.displayName"
|
||||
name="mapDisplayName"
|
||||
class="input"
|
||||
[placeholder]="providerType() === 'oidc' ? 'name' : 'cn'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Avatar URL (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.avatarUrl"
|
||||
name="mapAvatarUrl"
|
||||
class="input"
|
||||
placeholder="picture"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Groups (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.groups"
|
||||
name="mapGroups"
|
||||
class="input"
|
||||
[placeholder]="providerType() === 'oidc' ? 'groups' : 'memberOf'"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">For future group sync functionality</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provisioning Settings -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">User Provisioning</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.provisioning.jitEnabled"
|
||||
name="jitEnabled"
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="font-mono text-sm text-foreground">Just-in-Time Provisioning</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-muted-foreground pl-6">
|
||||
Automatically create user accounts on first login
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.provisioning.autoLinkByEmail"
|
||||
name="autoLinkByEmail"
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="font-mono text-sm text-foreground">Auto-Link by Email</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-muted-foreground pl-6">
|
||||
Automatically link to existing accounts with matching email addresses
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="label block mb-1.5">Allowed Email Domains (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="domainsInput"
|
||||
name="allowedDomains"
|
||||
class="input"
|
||||
placeholder="example.com, corp.example.com"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||
Comma-separated. Leave empty to allow all domains.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between items-center">
|
||||
<button type="button" (click)="cancel()" class="btn-secondary btn-md">Cancel</button>
|
||||
<div class="flex gap-3">
|
||||
@if (isEditMode()) {
|
||||
<button type="button" (click)="testConnection()" [disabled]="testing()" class="btn-secondary btn-md">
|
||||
@if (testing()) {
|
||||
Testing...
|
||||
} @else {
|
||||
Test Connection
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button type="submit" [disabled]="saving()" class="btn-primary btn-md">
|
||||
@if (saving()) {
|
||||
Saving...
|
||||
} @else {
|
||||
{{ isEditMode() ? 'Save Changes' : 'Create Provider' }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ProviderFormComponent implements OnInit {
|
||||
private adminAuthService = inject(AdminAuthService);
|
||||
private toastService = inject(ToastService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
testing = signal(false);
|
||||
isEditMode = signal(false);
|
||||
providerType = signal<TAuthProviderType>('oidc');
|
||||
providerId = signal<string | null>(null);
|
||||
|
||||
scopesInput = 'openid profile email';
|
||||
domainsInput = '';
|
||||
|
||||
form = {
|
||||
name: '',
|
||||
displayName: '',
|
||||
status: 'testing' as TAuthProviderStatus,
|
||||
priority: 0,
|
||||
oauthConfig: {
|
||||
clientId: '',
|
||||
clientSecretEncrypted: '',
|
||||
issuer: '',
|
||||
authorizationUrl: '',
|
||||
tokenUrl: '',
|
||||
userInfoUrl: '',
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
callbackUrl: '',
|
||||
},
|
||||
ldapConfig: {
|
||||
serverUrl: '',
|
||||
bindDn: '',
|
||||
bindPasswordEncrypted: '',
|
||||
baseDn: '',
|
||||
userSearchFilter: '(uid={{username}})',
|
||||
tlsEnabled: false,
|
||||
tlsCaCert: '',
|
||||
},
|
||||
attributeMapping: {
|
||||
email: 'email',
|
||||
username: 'preferred_username',
|
||||
displayName: 'name',
|
||||
avatarUrl: '',
|
||||
groups: '',
|
||||
},
|
||||
provisioning: {
|
||||
jitEnabled: true,
|
||||
autoLinkByEmail: true,
|
||||
allowedEmailDomains: [] as string[],
|
||||
},
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
// Check for edit mode
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (id && id !== 'new') {
|
||||
this.isEditMode.set(true);
|
||||
this.providerId.set(id);
|
||||
this.loadProvider(id);
|
||||
} else {
|
||||
// New provider mode
|
||||
const type = this.route.snapshot.queryParamMap.get('type') as TAuthProviderType;
|
||||
if (type && (type === 'oidc' || type === 'ldap')) {
|
||||
this.providerType.set(type);
|
||||
this.setDefaultMappings(type);
|
||||
}
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadProvider(id: string): Promise<void> {
|
||||
try {
|
||||
const provider = await this.adminAuthService.getProvider(id).toPromise();
|
||||
if (provider) {
|
||||
this.providerType.set(provider.type);
|
||||
this.form.name = provider.name;
|
||||
this.form.displayName = provider.displayName;
|
||||
this.form.status = provider.status;
|
||||
this.form.priority = provider.priority;
|
||||
|
||||
if (provider.oauthConfig) {
|
||||
this.form.oauthConfig = {
|
||||
...this.form.oauthConfig,
|
||||
...provider.oauthConfig,
|
||||
clientSecretEncrypted: '', // Don't show encrypted secret
|
||||
};
|
||||
this.scopesInput = provider.oauthConfig.scopes.join(' ');
|
||||
}
|
||||
|
||||
if (provider.ldapConfig) {
|
||||
this.form.ldapConfig = {
|
||||
...this.form.ldapConfig,
|
||||
...provider.ldapConfig,
|
||||
bindPasswordEncrypted: '', // Don't show encrypted password
|
||||
};
|
||||
}
|
||||
|
||||
if (provider.attributeMapping) {
|
||||
this.form.attributeMapping = { ...this.form.attributeMapping, ...provider.attributeMapping };
|
||||
}
|
||||
|
||||
if (provider.provisioning) {
|
||||
this.form.provisioning = { ...this.form.provisioning, ...provider.provisioning };
|
||||
this.domainsInput = provider.provisioning.allowedEmailDomains?.join(', ') || '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load provider');
|
||||
this.router.navigate(['/admin/auth']);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private setDefaultMappings(type: TAuthProviderType): void {
|
||||
if (type === 'ldap') {
|
||||
this.form.attributeMapping = {
|
||||
email: 'mail',
|
||||
username: 'uid',
|
||||
displayName: 'cn',
|
||||
avatarUrl: '',
|
||||
groups: 'memberOf',
|
||||
};
|
||||
this.form.ldapConfig.userSearchFilter = '(uid={{username}})';
|
||||
}
|
||||
}
|
||||
|
||||
getCallbackUrl(): string {
|
||||
const baseUrl = window.location.origin;
|
||||
const providerName = this.form.name || '{provider-name}';
|
||||
return `${baseUrl}/api/v1/auth/oauth/${providerName}/callback`;
|
||||
}
|
||||
|
||||
copyCallbackUrl(): void {
|
||||
navigator.clipboard.writeText(this.getCallbackUrl());
|
||||
this.toastService.success('Callback URL copied');
|
||||
}
|
||||
|
||||
async saveProvider(): Promise<void> {
|
||||
// Parse scopes and domains
|
||||
this.form.oauthConfig.scopes = this.scopesInput.split(/\s+/).filter(Boolean);
|
||||
this.form.provisioning.allowedEmailDomains = this.domainsInput
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
this.saving.set(true);
|
||||
|
||||
try {
|
||||
if (this.isEditMode()) {
|
||||
// Update existing provider
|
||||
const dto: IUpdateAuthProviderDto = {
|
||||
displayName: this.form.displayName,
|
||||
status: this.form.status,
|
||||
priority: this.form.priority,
|
||||
attributeMapping: this.form.attributeMapping,
|
||||
provisioning: this.form.provisioning,
|
||||
};
|
||||
|
||||
if (this.providerType() === 'oidc') {
|
||||
dto.oauthConfig = { ...this.form.oauthConfig };
|
||||
// Only include secret if changed
|
||||
if (!dto.oauthConfig.clientSecretEncrypted) {
|
||||
delete dto.oauthConfig.clientSecretEncrypted;
|
||||
}
|
||||
} else {
|
||||
dto.ldapConfig = { ...this.form.ldapConfig };
|
||||
// Only include password if changed
|
||||
if (!dto.ldapConfig.bindPasswordEncrypted) {
|
||||
delete dto.ldapConfig.bindPasswordEncrypted;
|
||||
}
|
||||
}
|
||||
|
||||
await this.adminAuthService.updateProvider(this.providerId()!, dto).toPromise();
|
||||
this.toastService.success('Provider updated');
|
||||
} else {
|
||||
// Create new provider
|
||||
const dto: ICreateAuthProviderDto = {
|
||||
name: this.form.name,
|
||||
displayName: this.form.displayName,
|
||||
type: this.providerType(),
|
||||
attributeMapping: this.form.attributeMapping,
|
||||
provisioning: this.form.provisioning,
|
||||
};
|
||||
|
||||
if (this.providerType() === 'oidc') {
|
||||
dto.oauthConfig = {
|
||||
...this.form.oauthConfig,
|
||||
callbackUrl: this.getCallbackUrl(),
|
||||
};
|
||||
} else {
|
||||
dto.ldapConfig = this.form.ldapConfig;
|
||||
}
|
||||
|
||||
await this.adminAuthService.createProvider(dto).toPromise();
|
||||
this.toastService.success('Provider created');
|
||||
}
|
||||
|
||||
this.router.navigate(['/admin/auth']);
|
||||
} catch (error: any) {
|
||||
const message = error?.error?.error || 'Failed to save provider';
|
||||
this.toastService.error(message);
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<void> {
|
||||
if (!this.providerId()) return;
|
||||
|
||||
this.testing.set(true);
|
||||
try {
|
||||
const result = await this.adminAuthService.testProvider(this.providerId()!).toPromise();
|
||||
if (result?.success) {
|
||||
this.toastService.success(`Connection successful (${result.latencyMs}ms)`);
|
||||
} else {
|
||||
this.toastService.error(result?.error || 'Connection failed');
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to test connection');
|
||||
} finally {
|
||||
this.testing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/admin/auth']);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
interface IPublicProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: 'oidc' | 'ldap';
|
||||
}
|
||||
|
||||
interface IProvidersResponse {
|
||||
providers: IPublicProvider[];
|
||||
localAuthEnabled: boolean;
|
||||
defaultProviderId?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -22,68 +37,202 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
<p class="font-mono text-sm text-muted-foreground mt-2 uppercase tracking-wider">Registry</p>
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<form (ngSubmit)="login()" class="card p-6 space-y-6">
|
||||
<!-- Terminal header -->
|
||||
<div class="code-header -mx-6 -mt-6 mb-6">
|
||||
<div class="terminal-dot dot-red"></div>
|
||||
<div class="terminal-dot dot-orange"></div>
|
||||
<div class="terminal-dot dot-green"></div>
|
||||
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">Sign In</span>
|
||||
@if (loadingProviders()) {
|
||||
<div class="card p-6 flex items-center justify-center">
|
||||
<svg class="animate-spin h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="label block mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
[(ngModel)]="email"
|
||||
name="email"
|
||||
class="input"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="label block mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
[(ngModel)]="password"
|
||||
name="password"
|
||||
class="input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="p-3 bg-destructive/10 border border-destructive/30">
|
||||
<p class="font-mono text-sm text-destructive">{{ error() }}</p>
|
||||
} @else {
|
||||
<!-- SSO Providers -->
|
||||
@if (oauthProviders().length > 0) {
|
||||
<div class="space-y-3 mb-6">
|
||||
@for (provider of oauthProviders(); track provider.id) {
|
||||
<button
|
||||
(click)="loginWithOAuth(provider)"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 bg-card flex items-center gap-4 transition-colors"
|
||||
>
|
||||
<div class="w-10 h-10 bg-primary/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-mono text-sm font-medium text-foreground">Continue with {{ provider.displayName }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
[disabled]="loading()"
|
||||
class="btn-primary btn-md w-full"
|
||||
>
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<!-- LDAP Providers -->
|
||||
@if (ldapProviders().length > 0 && !showLdapForm()) {
|
||||
<div class="space-y-3 mb-6">
|
||||
@for (provider of ldapProviders(); track provider.id) {
|
||||
<button
|
||||
(click)="selectLdapProvider(provider)"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 bg-card flex items-center gap-4 transition-colors"
|
||||
>
|
||||
<div class="w-10 h-10 bg-accent/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-mono text-sm font-medium text-foreground">Sign in with {{ provider.displayName }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- LDAP Login Form -->
|
||||
@if (showLdapForm() && selectedLdapProvider()) {
|
||||
<form (ngSubmit)="loginWithLdap()" class="card p-6 space-y-6 mb-6">
|
||||
<div class="code-header -mx-6 -mt-6 mb-6">
|
||||
<div class="terminal-dot dot-red"></div>
|
||||
<div class="terminal-dot dot-orange"></div>
|
||||
<div class="terminal-dot dot-green"></div>
|
||||
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">{{ selectedLdapProvider()!.displayName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="ldapUsername" class="label block mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ldapUsername"
|
||||
[(ngModel)]="ldapUsername"
|
||||
name="ldapUsername"
|
||||
class="input"
|
||||
placeholder="your.username"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ldapPassword" class="label block mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="ldapPassword"
|
||||
[(ngModel)]="ldapPassword"
|
||||
name="ldapPassword"
|
||||
class="input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (ldapError()) {
|
||||
<div class="p-3 bg-destructive/10 border border-destructive/30">
|
||||
<p class="font-mono text-sm text-destructive">{{ ldapError() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="button" (click)="cancelLdap()" class="btn-secondary btn-md flex-1">
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" [disabled]="ldapLoading()" class="btn-primary btn-md flex-1">
|
||||
@if (ldapLoading()) {
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
} @else {
|
||||
Sign in
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- Divider -->
|
||||
@if ((oauthProviders().length > 0 || ldapProviders().length > 0) && localAuthEnabled() && !showLdapForm()) {
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="flex-1 border-t border-border"></div>
|
||||
<span class="font-mono text-xs text-muted-foreground uppercase">or</span>
|
||||
<div class="flex-1 border-t border-border"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Local login form -->
|
||||
@if (localAuthEnabled() && !showLdapForm()) {
|
||||
<form (ngSubmit)="login()" class="card p-6 space-y-6">
|
||||
<!-- Terminal header -->
|
||||
<div class="code-header -mx-6 -mt-6 mb-6">
|
||||
<div class="terminal-dot dot-red"></div>
|
||||
<div class="terminal-dot dot-orange"></div>
|
||||
<div class="terminal-dot dot-green"></div>
|
||||
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">Sign In</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="label block mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
[(ngModel)]="email"
|
||||
name="email"
|
||||
class="input"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="label block mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
[(ngModel)]="password"
|
||||
name="password"
|
||||
class="input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="p-3 bg-destructive/10 border border-destructive/30">
|
||||
<p class="font-mono text-sm text-destructive">{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
[disabled]="loading()"
|
||||
class="btn-primary btn-md w-full"
|
||||
>
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
} @else {
|
||||
Sign in
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- No auth available message -->
|
||||
@if (!localAuthEnabled() && oauthProviders().length === 0 && ldapProviders().length === 0 && !showLdapForm()) {
|
||||
<div class="card p-6 text-center">
|
||||
<svg class="w-12 h-12 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Signing in...
|
||||
} @else {
|
||||
Sign in
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
<p class="font-mono text-sm text-muted-foreground">
|
||||
No authentication methods available. Please contact your administrator.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<p class="text-center font-mono text-xs text-muted-foreground mt-6 uppercase tracking-wider">
|
||||
Enterprise Package Registry
|
||||
@@ -92,16 +241,73 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
export class LoginComponent implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private toastService = inject(ToastService);
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// Local login
|
||||
email = '';
|
||||
password = '';
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
// Providers
|
||||
loadingProviders = signal(true);
|
||||
localAuthEnabled = signal(true);
|
||||
oauthProviders = signal<IPublicProvider[]>([]);
|
||||
ldapProviders = signal<IPublicProvider[]>([]);
|
||||
|
||||
// LDAP form
|
||||
showLdapForm = signal(false);
|
||||
selectedLdapProvider = signal<IPublicProvider | null>(null);
|
||||
ldapUsername = '';
|
||||
ldapPassword = '';
|
||||
ldapLoading = signal(false);
|
||||
ldapError = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
// Check for error in URL params
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const errorParam = params.get('error');
|
||||
if (errorParam) {
|
||||
this.error.set(decodeURIComponent(errorParam));
|
||||
}
|
||||
|
||||
this.loadProviders();
|
||||
}
|
||||
|
||||
private async loadProviders(): Promise<void> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<IProvidersResponse>('/api/v1/auth/providers')
|
||||
);
|
||||
|
||||
this.localAuthEnabled.set(response.localAuthEnabled);
|
||||
this.oauthProviders.set(response.providers.filter((p) => p.type === 'oidc'));
|
||||
this.ldapProviders.set(response.providers.filter((p) => p.type === 'ldap'));
|
||||
|
||||
// Auto-redirect to default provider if configured
|
||||
if (response.defaultProviderId && !this.error()) {
|
||||
const defaultProvider = response.providers.find((p) => p.id === response.defaultProviderId);
|
||||
if (defaultProvider) {
|
||||
if (defaultProvider.type === 'oidc') {
|
||||
this.loginWithOAuth(defaultProvider);
|
||||
return;
|
||||
} else if (defaultProvider.type === 'ldap') {
|
||||
this.selectLdapProvider(defaultProvider);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If providers endpoint fails, show local auth
|
||||
console.error('Failed to load providers:', error);
|
||||
} finally {
|
||||
this.loadingProviders.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
if (!this.email || !this.password) {
|
||||
this.error.set('Please enter your email and password');
|
||||
@@ -126,4 +332,62 @@ export class LoginComponent {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
loginWithOAuth(provider: IPublicProvider): void {
|
||||
// Redirect to OAuth authorization endpoint
|
||||
const returnUrl = encodeURIComponent(window.location.origin + '/dashboard');
|
||||
window.location.href = `/api/v1/auth/oauth/${provider.id}/authorize?returnUrl=${returnUrl}`;
|
||||
}
|
||||
|
||||
selectLdapProvider(provider: IPublicProvider): void {
|
||||
this.selectedLdapProvider.set(provider);
|
||||
this.showLdapForm.set(true);
|
||||
this.ldapUsername = '';
|
||||
this.ldapPassword = '';
|
||||
this.ldapError.set(null);
|
||||
}
|
||||
|
||||
cancelLdap(): void {
|
||||
this.showLdapForm.set(false);
|
||||
this.selectedLdapProvider.set(null);
|
||||
}
|
||||
|
||||
async loginWithLdap(): Promise<void> {
|
||||
const provider = this.selectedLdapProvider();
|
||||
if (!provider || !this.ldapUsername || !this.ldapPassword) {
|
||||
this.ldapError.set('Please enter your username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
this.ldapLoading.set(true);
|
||||
this.ldapError.set(null);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<{
|
||||
user: { id: string; email: string; username: string; displayName: string; isSystemAdmin: boolean };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
sessionId: string;
|
||||
}>(`/api/v1/auth/ldap/${provider.id}/login`, {
|
||||
username: this.ldapUsername,
|
||||
password: this.ldapPassword,
|
||||
})
|
||||
);
|
||||
|
||||
this.authService.handleOAuthCallback(
|
||||
response.accessToken,
|
||||
response.refreshToken,
|
||||
response.sessionId
|
||||
);
|
||||
|
||||
this.toastService.success('Welcome!');
|
||||
this.router.navigate(['/dashboard']);
|
||||
} catch (err: any) {
|
||||
const message = err?.error?.error || 'Authentication failed';
|
||||
this.ldapError.set(message);
|
||||
} finally {
|
||||
this.ldapLoading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-oauth-callback',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div class="max-w-md w-full text-center">
|
||||
@if (error()) {
|
||||
<div class="w-16 h-16 bg-destructive/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-10 h-10 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="font-mono text-xl font-bold text-foreground mb-2">Authentication Failed</h1>
|
||||
<p class="font-mono text-sm text-muted-foreground mb-6">{{ error() }}</p>
|
||||
<a href="/login" class="btn-primary btn-md">Back to Login</a>
|
||||
} @else {
|
||||
<div class="w-16 h-16 bg-primary flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="animate-spin w-10 h-10 text-primary-foreground" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="font-mono text-xl font-bold text-foreground mb-2">Signing you in...</h1>
|
||||
<p class="font-mono text-sm text-muted-foreground">Please wait while we complete authentication</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OAuthCallbackComponent implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
error = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.handleCallback();
|
||||
}
|
||||
|
||||
private handleCallback(): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = params.get('accessToken');
|
||||
const refreshToken = params.get('refreshToken');
|
||||
const sessionId = params.get('sessionId');
|
||||
const errorParam = params.get('error');
|
||||
|
||||
if (errorParam) {
|
||||
this.error.set(decodeURIComponent(errorParam));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken || !refreshToken || !sessionId) {
|
||||
this.error.set('Missing authentication tokens');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the tokens and redirect
|
||||
this.authService.handleOAuthCallback(accessToken, refreshToken, sessionId);
|
||||
this.toastService.success('Welcome!');
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-layout',
|
||||
@@ -65,6 +64,20 @@ import { ToastService } from '../../../core/services/toast.service';
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
|
||||
<!-- Admin Section -->
|
||||
@if (isAdmin()) {
|
||||
<div class="pt-4 mt-4 border-t border-border">
|
||||
<p class="px-3 mb-2 font-mono text-xs text-muted-foreground uppercase tracking-wider">Administration</p>
|
||||
<a routerLink="/admin/auth" routerLinkActive="bg-primary/10 text-primary"
|
||||
class="nav-link">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Authentication
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- User section -->
|
||||
@@ -108,6 +121,7 @@ export class LayoutComponent {
|
||||
const name = this.authService.user()?.displayName || 'U';
|
||||
return name.charAt(0).toUpperCase();
|
||||
});
|
||||
isAdmin = computed(() => this.authService.isAdmin());
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
|
||||
Reference in New Issue
Block a user