feat(auth): Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support

This commit is contained in:
2025-12-03 22:09:35 +00:00
parent 44e92d48f2
commit d3fd40ce2f
27 changed files with 4512 additions and 61 deletions

View File

@@ -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`;
}
}