feat: Implement authentication provider management components

- Added new templates for managing authentication providers, including forms for creating and editing providers.
- Implemented backend logic to handle retrieval, creation, and deletion of authentication providers.
- Introduced new database migrations to support the storage of authentication provider data.
- Enhanced the user interface to display available authentication providers and their statuses.
- Added routes and handlers for managing authentication provider actions in the web application.
This commit is contained in:
StarFleetCPTN
2025-03-24 14:51:38 -07:00
parent 233f1779d8
commit b91b0e8796
21 changed files with 3524 additions and 360 deletions
+655
View File
@@ -0,0 +1,655 @@
package components
import (
"fmt"
"encoding/json"
"context"
"github.com/starfleetcptn/gomft/internal/db"
)
templ AuthProviderForm(ctx context.Context, provider *db.AuthProvider, isNew bool) {
@LayoutWithContext(getPageTitle(isNew), ctx) {
<div id="auth-provider-form-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-provider-form-page bg-gray-50 dark:bg-gray-900">
<div class="p-4 pb-8 w-full">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div>
if isNew {
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
New Authentication Provider
</h1>
<p class="text-gray-500 dark:text-gray-400">Configure a new external authentication source</p>
} else {
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Edit Authentication Provider
</h1>
<p class="text-gray-500 dark:text-gray-400">Update an existing external authentication source</p>
}
</div>
<div>
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-4 py-2 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i> Back to Providers
</a>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
if isNew {
<form method="POST" action="/admin/settings/auth-providers" class="space-y-6">
@formContent(provider, isNew)
</form>
} else {
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/admin/settings/auth-providers/%d", provider.ID)) } class="space-y-6">
<input type="hidden" name="_method" value="PUT" />
@formContent(provider, isNew)
</form>
}
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Make sure to enter the correct callback URL in your external provider's configuration.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Configure attribute mappings to match the fields in your identity provider's user data.</p>
</div>
</div>
</div>
</div>
</div>
<script>
function updateFormFields() {
// Hide all provider-specific sections first
document.querySelectorAll('.provider-specific-section').forEach(section => {
section.style.display = 'none';
});
// Show the relevant section based on selected provider type
const providerType = document.getElementById('type').value;
if (providerType === 'authentik') {
document.getElementById('authentikSection').style.display = 'block';
} else if (providerType === 'oidc') {
document.getElementById('oidcSection').style.display = 'block';
} else if (providerType === 'saml') {
document.getElementById('samlSection').style.display = 'block';
}
}
function testProviderConnection() {
const providerId = window.location.pathname.split('/').slice(-2)[0];
const testBtn = event.target;
const originalText = testBtn.innerHTML;
testBtn.disabled = true;
testBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Testing...';
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.error) {
showToast('error', data.error);
} else if (data.success) {
showToast('success', data.message || 'Connection test successful');
} else {
showToast('error', 'Connection test failed');
}
testBtn.disabled = false;
testBtn.innerHTML = originalText;
})
.catch(error => {
console.error('Error:', error);
showToast('error', 'Connection test failed');
testBtn.disabled = false;
testBtn.innerHTML = originalText;
});
}
// Handle icon URL preview
function updateIconPreview() {
const iconUrl = document.getElementById('icon_url').value;
const previewImg = document.getElementById('preview-img');
const previewPlaceholder = document.getElementById('preview-placeholder');
if (iconUrl && iconUrl.trim() !== '') {
// Create image element if it doesn't exist
if (!previewImg) {
const img = document.createElement('img');
img.id = 'preview-img';
img.className = 'max-w-full max-h-full';
img.onerror = function() {
// If image fails to load, show placeholder
this.style.display = 'none';
if (previewPlaceholder) {
previewPlaceholder.style.display = 'block';
} else {
const icon = document.createElement('i');
icon.id = 'preview-placeholder';
icon.className = 'fas fa-exclamation-circle text-red-500 text-xl';
document.getElementById('icon-preview').appendChild(icon);
}
};
img.onload = function() {
// If image loads successfully, hide placeholder
this.style.display = 'block';
if (previewPlaceholder) {
previewPlaceholder.style.display = 'none';
}
};
document.getElementById('icon-preview').appendChild(img);
}
// Update image source
if (previewImg) {
previewImg.src = iconUrl;
previewImg.style.display = 'block';
if (previewPlaceholder) {
previewPlaceholder.style.display = 'none';
}
}
} else {
// If no URL, show placeholder
if (previewImg) {
previewImg.style.display = 'none';
}
if (previewPlaceholder) {
previewPlaceholder.style.display = 'block';
}
}
}
// Initialize the form on page load
document.addEventListener('DOMContentLoaded', function() {
updateFormFields();
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('auth-provider-form-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('auth-provider-form-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
// Add event listener for icon URL changes
const iconUrlInput = document.getElementById('icon_url');
if (iconUrlInput) {
iconUrlInput.addEventListener('input', updateIconPreview);
// Initial preview
updateIconPreview();
}
});
</script>
}
}
func getPageTitle(isNew bool) string {
if isNew {
return "New Authentication Provider"
}
return "Edit Authentication Provider"
}
templ formContent(provider *db.AuthProvider, isNew bool) {
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name <span class="text-red-600">*</span></label>
<input
type="text"
id="name"
name="name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="e.g. Authentik SSO"
required
value={ getValue(provider, "name") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A descriptive name for this authentication provider</p>
</div>
<!-- Provider Type -->
<div>
<label for="type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider Type <span class="text-red-600">*</span></label>
<select id="type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required onchange="updateFormFields()">
<option value="">Select a provider type</option>
if provider != nil && provider.Type == db.ProviderTypeAuthentik {
<option value="authentik" selected>Authentik</option>
} else {
<option value="authentik">Authentik</option>
}
if provider != nil && provider.Type == db.ProviderTypeOIDC {
<option value="oidc" selected>OpenID Connect (OIDC)</option>
} else {
<option value="oidc">OpenID Connect (OIDC)</option>
}
if provider != nil && provider.Type == db.ProviderTypeSAML {
<option value="saml" selected>SAML 2.0</option>
} else {
<option value="saml">SAML 2.0</option>
}
if provider != nil && provider.Type == db.ProviderTypeOAuth2 {
<option value="oauth2" selected>OAuth 2.0</option>
} else {
<option value="oauth2">OAuth 2.0</option>
}
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The type of external authentication service</p>
</div>
<!-- Enabled -->
<div>
<div class="flex items-center">
if provider == nil || provider.Enabled {
<input
type="checkbox"
id="enabled"
name="enabled"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
checked
/>
} else {
<input
type="checkbox"
id="enabled"
name="enabled"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
}
<label for="enabled" class="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Whether this authentication provider is active and available for login</p>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<textarea
id="description"
name="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
rows="3"
placeholder="Optional description of this provider"
>
if provider != nil {
{ provider.Description }
}
</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Additional information about this authentication provider</p>
</div>
</div>
<!-- Icon URL -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Provider Icon</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="icon_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon URL</label>
<input
type="url"
id="icon_url"
name="icon_url"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://example.com/icon.svg"
value={ provider.IconURL }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the provider's icon image (SVG recommended)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon Preview</label>
<div class="flex items-center">
<div id="icon-preview" class="border border-gray-300 dark:border-gray-700 rounded-lg p-4 flex items-center justify-center w-20 h-20 bg-white dark:bg-gray-800">
if provider != nil && provider.IconURL != "" {
<img src={ provider.IconURL } class="max-w-full max-h-full" id="preview-img" />
} else {
<i class="fas fa-image text-gray-400 text-xl" id="preview-placeholder"></i>
}
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Preview of the icon that will be displayed on the login button.</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no URL is provided, a default icon will be used based on the provider type.</p>
</div>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Connection Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Provider URL -->
<div>
<label for="provider_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider URL <span class="text-red-600">*</span></label>
<input
type="url"
id="provider_url"
name="provider_url"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://authentik.example.com"
required
value={ getValue(provider, "provider_url") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The base URL of the authentication provider</p>
</div>
<!-- Client ID -->
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID <span class="text-red-600">*</span></label>
<input
type="text"
id="client_id"
name="client_id"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
required
value={ getValue(provider, "client_id") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client identifier assigned by the authentication provider</p>
</div>
<!-- Client Secret -->
<div>
<label for="client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Client Secret
if !isNew {
<span class="text-gray-500 text-xs font-normal ml-2">(leave empty to keep current)</span>
} else {
<span class="text-red-600">*</span>
}
</label>
if isNew {
<input
type="password"
id="client_secret"
name="client_secret"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
required
/>
} else {
<input
type="password"
id="client_secret"
name="client_secret"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
/>
}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client secret for authentication with the provider</p>
</div>
<!-- Redirect URL -->
<div>
<label for="redirect_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Redirect URL <span class="text-red-600">*</span></label>
<input
type="url"
id="redirect_url"
name="redirect_url"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://your-app.example.com/auth/callback"
required
value={ getRedirectURL(provider) }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The callback URL that will handle the authentication response</p>
</div>
<!-- Scopes -->
<div>
<label for="scopes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scopes</label>
<input
type="text"
id="scopes"
name="scopes"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="openid profile email"
value={ getValue(provider, "scopes") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Space-separated list of scopes to request from the provider</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="authentikSection">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Authentik Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Authentik Tenant ID -->
<div>
<label for="authentik_tenant" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tenant ID</label>
<input
type="text"
id="authentik_tenant"
name="authentik_tenant"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="default"
value={ getConfigValue(provider, "tenant_id") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Authentik tenant ID (optional, defaults to 'default')</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="oidcSection">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">OpenID Connect Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- OIDC Discovery URL -->
<div>
<label for="oidc_discovery_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Discovery URL</label>
<input
type="url"
id="oidc_discovery_url"
name="oidc_discovery_url"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://provider/.well-known/openid-configuration"
value={ getConfigValue(provider, "discovery_url") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the OIDC discovery document</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="samlSection">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">SAML Settings</h3>
<div class="grid grid-cols-1 gap-6">
<!-- SAML Metadata URL -->
<div>
<label for="saml_metadata_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Metadata URL</label>
<input
type="url"
id="saml_metadata_url"
name="saml_metadata_url"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://provider/metadata.xml"
value={ getConfigValue(provider, "metadata_url") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the SAML metadata XML</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">User Attribute Mapping</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Username Attribute -->
<div>
<label for="attr_username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username Attribute</label>
<input
type="text"
id="attr_username"
name="attr_username"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="preferred_username"
value={ getAttributeValue(provider, "username") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the username</p>
</div>
<!-- Email Attribute -->
<div>
<label for="attr_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Attribute</label>
<input
type="text"
id="attr_email"
name="attr_email"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="email"
value={ getAttributeValue(provider, "email") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the email address</p>
</div>
<!-- Display Name Attribute -->
<div>
<label for="attr_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Display Name Attribute</label>
<input
type="text"
id="attr_name"
name="attr_name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="name"
value={ getAttributeValue(provider, "name") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the display name</p>
</div>
<!-- Groups Attribute -->
<div>
<label for="attr_groups" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Groups Attribute</label>
<input
type="text"
id="attr_groups"
name="attr_groups"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="groups"
value={ getAttributeValue(provider, "groups") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute that contains user groups</p>
</div>
</div>
</div>
<div class="flex justify-end gap-4 mt-6">
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
Cancel
</a>
if !isNew {
<button type="button" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700" onclick="testProviderConnection()">
<i class="fas fa-check-circle w-4 h-4 mr-2"></i>
Test Connection
</button>
}
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
if isNew {
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Create Provider
} else {
<i class="fas fa-save w-4 h-4 mr-2"></i>
Save Changes
}
</button>
</div>
}
func getValue(provider *db.AuthProvider, field string) string {
if provider == nil {
return ""
}
switch field {
case "name":
return provider.Name
case "provider_url":
return provider.ProviderURL
case "client_id":
return provider.ClientID
case "redirect_url":
return provider.RedirectURL
case "scopes":
return provider.Scopes
case "icon_url":
return provider.IconURL
default:
return ""
}
}
func getRedirectURL(provider *db.AuthProvider) string {
if provider == nil || provider.RedirectURL == "" {
return fmt.Sprintf("https://%s/auth/callback", "your-app-domain.com")
}
return provider.RedirectURL
}
func getConfigValue(provider *db.AuthProvider, key string) string {
if provider == nil || provider.Config == "" {
return ""
}
var config map[string]interface{}
if err := json.Unmarshal([]byte(provider.Config), &config); err != nil {
return ""
}
if value, ok := config[key]; ok {
if strValue, ok := value.(string); ok {
return strValue
}
}
return ""
}
func getAttributeValue(provider *db.AuthProvider, key string) string {
if provider == nil || provider.AttributeMapping == "" {
return ""
}
var mapping map[string]string
if err := json.Unmarshal([]byte(provider.AttributeMapping), &mapping); err != nil {
return ""
}
if value, ok := mapping[key]; ok {
return value
}
return ""
}
+380
View File
@@ -0,0 +1,380 @@
package components
import (
"context"
"fmt"
"time"
"github.com/starfleetcptn/gomft/internal/db"
)
// formatTime formats a time.Time value as a human-readable string
func formatTime(t time.Time) string {
return t.Format("Jan 02, 2006 15:04")
}
templ AuthProviders(ctx context.Context, providers []db.AuthProvider) {
@LayoutWithContext("Authentication Providers", ctx) {
<!-- Toast container for notifications -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<div id="auth-providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-providers-page bg-gray-50 dark:bg-gray-900">
<div class="p-4 pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Authentication Providers
</h1>
<p class="text-gray-500 dark:text-gray-400">Manage external authentication sources like Authentik, OIDC, SAML, etc.</p>
</div>
<div>
<a href="/admin/settings/auth-providers/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
New Provider
</a>
</div>
</div>
<div class="mt-6">
if len(providers) > 0 {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left rtl:text-right">
<thead class="text-xs uppercase bg-gray-100 dark:bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3">Name</th>
<th scope="col" class="px-6 py-3">Icon</th>
<th scope="col" class="px-6 py-3">Type</th>
<th scope="col" class="px-6 py-3">Status</th>
<th scope="col" class="px-6 py-3">Provider URL</th>
<th scope="col" class="px-6 py-3">Last Used</th>
<th scope="col" class="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody>
for _, provider := range providers {
<tr class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">
{ provider.Name }
</td>
<td class="px-6 py-4">
<div class="flex items-center justify-center">
if provider.IconURL != "" {
<img src={ provider.IconURL } class="w-6 h-6" alt={ provider.Name + " icon" } />
} else {
if provider.Type == db.ProviderTypeAuthentik {
<img src="/static/img/authentik.svg" class="w-6 h-6" alt="Authentik" />
} else if provider.Type == db.ProviderTypeOIDC {
<img src="/static/img/oidc.svg" class="w-6 h-6" alt="OIDC" />
} else if provider.Type == db.ProviderTypeSAML {
<img src="/static/img/saml.svg" class="w-6 h-6" alt="SAML" />
} else if provider.Type == db.ProviderTypeOAuth2 {
<img src="/static/img/oauth2.svg" class="w-6 h-6" alt="OAuth2" />
} else {
<i class="fas fa-user-shield text-blue-500 text-lg"></i>
}
}
</div>
</td>
<td class="px-6 py-4 uppercase">
{ string(provider.Type) }
</td>
<td class="px-6 py-4">
if provider.Enabled {
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
} else {
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
Disabled
</span>
}
</td>
<td class="px-6 py-4 max-w-[200px] truncate">
{ provider.ProviderURL }
</td>
<td class="px-6 py-4">
if provider.LastUsed.Valid {
{ formatTime(provider.LastUsed.Time) }
} else {
<span class="text-gray-400 dark:text-gray-500">Never</span>
}
</td>
<td class="px-6 py-4">
<div class="flex gap-2">
<a href={ templ.SafeURL("/admin/settings/auth-providers/" + fmt.Sprint(provider.ID) + "/edit") }
class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700"
title="Edit">
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
Edit
</a>
<button
type="button"
data-provider-id={ fmt.Sprint(provider.ID) }
data-provider-name={ provider.Name }
class="delete-provider-btn text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
title="Delete">
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
Delete
</button>
<button
data-provider-id={ fmt.Sprint(provider.ID) }
onclick="testProvider(this)"
class="text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-blue-700 dark:text-blue-300 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
title="Test Connection">
<i class="fas fa-check-circle w-3.5 h-3.5 mr-1.5"></i>
Test
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
} else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
<i class="fas fa-user-shield text-gray-400 dark:text-gray-500 text-3xl"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No Authentication Providers</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">
You haven't set up any external authentication providers yet.
</p>
<a href="/admin/settings/auth-providers/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Add First Provider
</a>
</div>
}
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Make sure to configure callback URLs in your provider's settings.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Test your connections to ensure proper communication with external authentication systems.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Provider Modal using Flowbite style -->
<div id="deleteProviderModal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete the provider <strong id="providerNameConfirm"></strong>?</h3>
<button
type="button"
id="confirmDeleteBtn"
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center">
Delete
</button>
<button type="button" onclick="closeDeleteModal()" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
</div>
</div>
</div>
<script>
// Toast notification function
function showToast(type, message) {
const toastContainer = document.getElementById('toast-container');
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else {
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
function confirmDeleteProvider(providerId, providerName) {
document.getElementById('providerNameConfirm').textContent = providerName;
document.getElementById('confirmDeleteBtn').onclick = () => deleteProvider(providerId);
// Show the modal
document.getElementById('deleteProviderModal').classList.remove('hidden');
document.getElementById('deleteProviderModal').classList.add('flex');
}
function closeDeleteModal() {
document.getElementById('deleteProviderModal').classList.add('hidden');
document.getElementById('deleteProviderModal').classList.remove('flex');
}
function deleteProvider(providerId) {
fetch(`/admin/settings/auth-providers/${providerId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.error) {
showToast('error', data.error);
} else {
showToast('success', 'Authentication provider deleted successfully');
setTimeout(() => {
window.location.reload();
}, 1000);
}
closeDeleteModal();
})
.catch(error => {
console.error('Error:', error);
showToast('error', 'Failed to delete authentication provider');
closeDeleteModal();
});
}
function testProvider(btn) {
const providerId = btn.getAttribute('data-provider-id');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin w-3.5 h-3.5 mr-1.5"></i> Testing...';
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.error) {
showToast('error', data.error);
} else if (data.success) {
showToast('success', data.message || 'Connection test successful');
} else {
showToast('error', 'Connection test failed');
}
btn.disabled = false;
btn.innerHTML = originalText;
})
.catch(error => {
console.error('Error:', error);
showToast('error', 'Connection test failed');
btn.disabled = false;
btn.innerHTML = originalText;
});
}
// Set dark background color if in dark mode
document.addEventListener('DOMContentLoaded', function() {
if (document.documentElement.classList.contains('dark')) {
document.getElementById('auth-providers-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('auth-providers-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
// Set up event listeners for delete buttons
document.querySelectorAll('.delete-provider-btn').forEach(button => {
button.addEventListener('click', function() {
const providerId = this.getAttribute('data-provider-id');
const providerName = this.getAttribute('data-provider-name');
confirmDeleteProvider(providerId, providerName);
});
});
});
</script>
}
}
+52
View File
@@ -0,0 +1,52 @@
package components
import (
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
// getProviderIcon returns the appropriate icon for a provider
func getProviderIcon(provider db.AuthProvider) templ.Component {
// If provider has a custom icon URL, use it
if provider.IconURL != "" {
return templ.Raw(fmt.Sprintf(`<img src="%s" class="w-5 h-5" alt="%s icon" />`, provider.IconURL, provider.Name))
}
// Otherwise fall back to default icons based on type
switch provider.Type {
case db.ProviderTypeAuthentik:
return templ.Raw(`<img src="/static/img/authentik.svg" class="w-5 h-5" alt="Authentik" />`)
case db.ProviderTypeOIDC:
return templ.Raw(`<img src="/static/img/oidc.svg" class="w-5 h-5" alt="OIDC" />`)
case db.ProviderTypeSAML:
return templ.Raw(`<img src="/static/img/saml.svg" class="w-5 h-5" alt="SAML" />`)
case db.ProviderTypeOAuth2:
return templ.Raw(`<img src="/static/img/oauth2.svg" class="w-5 h-5" alt="OAuth2" />`)
default:
return templ.Raw(`<i class="fas fa-user-shield text-blue-500"></i>`)
}
}
templ AuthProviderButtons(providers []db.AuthProvider) {
if len(providers) == 0 {
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
No external authentication providers available
</div>
} else {
<div class="space-y-2 w-full">
for _, provider := range providers {
if provider.Enabled {
<a
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
>
<span class="flex-shrink-0 w-5 h-5 mr-2.5">
@getProviderIcon(provider)
</span>
<span>{ provider.Name }</span>
</a>
}
}
</div>
}
}
+18 -3
View File
@@ -228,10 +228,25 @@ templ LayoutWithContext(title string, ctx context.Context) {
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Database Tools
</a>
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
// Settings Dropdown
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Settings
</a>
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
<li>
<a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
</li>
<li>
<a href="/admin/settings/auth-providers" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">Authentication Providers</a>
</li>
<li>
<a href="/admin/settings" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">Notifications</a>
</li>
</ul>
}
</div>
</nav>
+11 -1
View File
@@ -111,10 +111,20 @@ templ Login(ctx context.Context, errorMessage string) {
</form>
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-300">
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">
<i class="fas fa-info-circle mr-1"></i>
Contact an administrator to create an account
</p>
<!-- External Authentication Providers -->
<div id="external-auth-providers" class="mt-4">
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p>
<div id="provider-buttons" class="flex flex-col gap-2" hx-get="/auth/providers" hx-trigger="load" hx-target="#provider-buttons">
<div class="animate-pulse flex justify-center">
<div class="h-10 bg-gray-200 rounded w-full max-w-[200px] dark:bg-gray-700"></div>
</div>
</div>
</div>
</div>
</div>
+649 -356
View File
File diff suppressed because it is too large Load Diff