523 lines
23 KiB
TypeScript
523 lines
23 KiB
TypeScript
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`;
|
|
}
|
|
}
|