mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
726 lines
30 KiB
TypeScript
726 lines
30 KiB
TypeScript
import { Component, createSignal, Show, For, onMount, lazy, Suspense } from 'solid-js';
|
|
import { logger } from '@/utils/logger';
|
|
import { apiClient, apiFetchJSON } from '@/utils/apiClient';
|
|
import { STORAGE_KEYS } from '@/utils/localStorage';
|
|
import Globe from 'lucide-solid/icons/globe';
|
|
import Key from 'lucide-solid/icons/key';
|
|
|
|
const SetupWizard = lazy(() =>
|
|
import('./SetupWizard').then((m) => ({ default: m.SetupWizard })),
|
|
);
|
|
|
|
interface LoginProps {
|
|
onLogin: () => void;
|
|
hasAuth?: boolean; // If true, auth is configured (passed from App.tsx to skip redundant check)
|
|
securityStatus?: SecurityStatus; // Full security status from App.tsx to avoid redundant API call
|
|
}
|
|
|
|
import type { SecurityStatus, SSOProviderInfo } from '@/types/config';
|
|
|
|
export const Login: Component<LoginProps> = (props) => {
|
|
const [username, setUsername] = createSignal('');
|
|
const [password, setPassword] = createSignal('');
|
|
const [rememberMe, setRememberMe] = createSignal(false);
|
|
const [error, setError] = createSignal('');
|
|
const [loading, setLoading] = createSignal(false);
|
|
const [authStatus, setAuthStatus] = createSignal<SecurityStatus | null>(props.securityStatus ?? null);
|
|
// If hasAuth is passed from App.tsx, we already know auth status - skip the loading state
|
|
// Also skip if securityStatus is provided
|
|
const [loadingAuth, setLoadingAuth] = createSignal(props.hasAuth === undefined && !props.securityStatus);
|
|
const [oidcLoading, setOidcLoading] = createSignal(false);
|
|
const [oidcError, setOidcError] = createSignal('');
|
|
const [oidcMessage, setOidcMessage] = createSignal('');
|
|
|
|
const supportsOIDC = () => Boolean(authStatus()?.oidcEnabled);
|
|
const ssoProviders = () => authStatus()?.ssoProviders || [];
|
|
|
|
const resolveSSOError = (reason?: string | null) => {
|
|
switch (reason) {
|
|
case 'email_restricted':
|
|
return 'Your account email is not permitted to access Pulse.';
|
|
case 'domain_restricted':
|
|
return 'Your email domain is not allowed for Pulse access.';
|
|
case 'group_restricted':
|
|
return 'Your account is not part of an authorized group to use Pulse.';
|
|
case 'invalid_state':
|
|
return 'The sign-in attempt expired. Please try again.';
|
|
case 'exchange_failed':
|
|
return 'We could not complete the sign-in request. Please try again shortly.';
|
|
case 'session_failed':
|
|
return 'Login succeeded but we could not create a session. Try again.';
|
|
case 'invalid_id_token':
|
|
return 'ID token verification failed. Check that OIDC_ISSUER_URL matches the issuer claim in your provider tokens (check server logs for details).';
|
|
case 'invalid_signature_alg':
|
|
return 'The identity provider is issuing HS256 tokens. Configure it to sign ID tokens with RS256 (see your IdP\'s OIDC settings).';
|
|
case 'invalid_nonce':
|
|
return 'Security validation failed (nonce mismatch). Please try again.';
|
|
case 'saml_validation_failed':
|
|
return 'SAML assertion validation failed. Please try again or contact an administrator.';
|
|
case 'provider_not_found':
|
|
return 'SSO provider not found or not configured.';
|
|
case 'provider_not_initialized':
|
|
return 'SSO provider is not properly configured. Contact your administrator.';
|
|
default:
|
|
return 'Single sign-on failed. Please try again or contact an administrator.';
|
|
}
|
|
};
|
|
|
|
onMount(async () => {
|
|
// Apply saved theme preference from localStorage
|
|
const savedTheme = localStorage.getItem(STORAGE_KEYS.DARK_MODE);
|
|
if (savedTheme === 'false') {
|
|
document.documentElement.classList.remove('dark');
|
|
} else if (savedTheme === 'true') {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
// No saved preference - use system preference
|
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
}
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
// Handle OIDC callback
|
|
const oidcStatus = params.get('oidc');
|
|
if (oidcStatus === 'error') {
|
|
const reason = params.get('oidc_error');
|
|
setOidcError(resolveSSOError(reason));
|
|
setError('');
|
|
} else if (oidcStatus === 'success') {
|
|
setOidcMessage('Signed in successfully. Loading Pulse…');
|
|
setError('');
|
|
}
|
|
|
|
// Handle SAML callback
|
|
const samlStatus = params.get('saml');
|
|
if (samlStatus === 'error') {
|
|
const reason = params.get('saml_error');
|
|
setOidcError(resolveSSOError(reason));
|
|
setError('');
|
|
} else if (samlStatus === 'success') {
|
|
setOidcMessage('Signed in successfully. Loading Pulse…');
|
|
setError('');
|
|
}
|
|
|
|
// Clean up URL parameters
|
|
if (oidcStatus || samlStatus) {
|
|
params.delete('oidc');
|
|
params.delete('oidc_error');
|
|
params.delete('saml');
|
|
params.delete('saml_error');
|
|
const newQuery = params.toString();
|
|
const newUrl = `${window.location.pathname}${newQuery ? `?${newQuery}` : ''}`;
|
|
window.history.replaceState({}, document.title, newUrl);
|
|
}
|
|
|
|
// If securityStatus was passed from App.tsx, use it directly without making another API call
|
|
// This eliminates the flicker between "Checking authentication..." and the login form
|
|
// AND ensures hideLocalLogin, oidcEnabled, etc. are properly respected
|
|
if (props.securityStatus) {
|
|
logger.debug('[Login] Using securityStatus from App.tsx, skipping redundant auth check', props.securityStatus);
|
|
setAuthStatus(props.securityStatus);
|
|
setLoadingAuth(false);
|
|
return;
|
|
}
|
|
|
|
// Legacy fallback: if only hasAuth was passed (without full securityStatus)
|
|
if (props.hasAuth !== undefined && !props.securityStatus) {
|
|
logger.debug('[Login] Using hasAuth from App.tsx (legacy), fetching full security status');
|
|
// Still need to fetch full status to get hideLocalLogin, OIDC settings, etc.
|
|
}
|
|
|
|
logger.debug('[Login] Starting auth check...');
|
|
try {
|
|
const data = await apiFetchJSON<SecurityStatus>('/api/security/status');
|
|
logger.debug('[Login] Auth status data', data);
|
|
setAuthStatus(data);
|
|
} catch (err: any) {
|
|
// Check for 429
|
|
// apiFetchJSON throws error with status attached? No, simple Error map.
|
|
// But if needed we can parse error message if it contains "Too Many Requests"
|
|
|
|
// Just assume no auth on error, matching previous logic mostly.
|
|
logger.error('[Login] Failed to check auth status:', err);
|
|
setAuthStatus({ hasAuthentication: false } as SecurityStatus);
|
|
} finally {
|
|
logger.debug('[Login] Auth check complete, setting loading to false');
|
|
setLoadingAuth(false);
|
|
}
|
|
});
|
|
|
|
const startOidcLogin = () => {
|
|
if (!supportsOIDC()) return;
|
|
|
|
setOidcError('');
|
|
setOidcMessage('');
|
|
setError('');
|
|
setOidcLoading(true);
|
|
|
|
// Navigate directly to the OIDC login endpoint using GET.
|
|
// The server will respond with an HTTP redirect to the OIDC provider.
|
|
// This guarantees same-window navigation in all browsers, including Arc.
|
|
const returnTo = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
|
window.location.href = `/api/oidc/login?returnTo=${returnTo}`;
|
|
};
|
|
|
|
// Auto-redirect to OIDC is intentionally disabled to prevent redirect loops
|
|
// when both password and OIDC are configured. Users must manually click OIDC button.
|
|
|
|
const handleSubmit = async (e: Event) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
setLoading(true);
|
|
|
|
// Read values directly from the form DOM to handle password manager autofill
|
|
// Password managers may fill fields without triggering input events,
|
|
// leaving the SolidJS signals empty while the DOM has the actual values
|
|
const form = e.currentTarget as HTMLFormElement;
|
|
const usernameInput = form.querySelector('#username') as HTMLInputElement;
|
|
const passwordInput = form.querySelector('#password') as HTMLInputElement;
|
|
const usernameValue = usernameInput?.value || username();
|
|
const passwordValue = passwordInput?.value || password();
|
|
|
|
// Validate that we have credentials before attempting login
|
|
if (!usernameValue || !passwordValue) {
|
|
setError('Please enter both username and password');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Use the new login endpoint for better feedback
|
|
const response = await apiClient.fetch('/api/login', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
username: usernameValue,
|
|
password: passwordValue,
|
|
rememberMe: rememberMe(),
|
|
}),
|
|
skipAuth: true,
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
// Credentials are valid; persist username for convenience and rely on session cookie
|
|
try {
|
|
sessionStorage.setItem('pulse_auth_user', usernameValue);
|
|
} catch (_err) {
|
|
// Ignore storage failures (private browsing, etc.)
|
|
}
|
|
props.onLogin();
|
|
} else if (response.status === 403) {
|
|
// Account is locked
|
|
if (data.remainingMinutes) {
|
|
setError(
|
|
`Account locked. Please try again in ${data.remainingMinutes} ${data.remainingMinutes === 1 ? 'minute' : 'minutes'}.`,
|
|
);
|
|
} else {
|
|
setError(data.message || 'Account temporarily locked due to too many failed attempts.');
|
|
}
|
|
// Clear the input fields
|
|
setUsername('');
|
|
setPassword('');
|
|
} else if (response.status === 429) {
|
|
// Rate limited
|
|
setError(data.message || 'Too many requests. Please wait a moment and try again.');
|
|
} else if (response.status === 401) {
|
|
// Invalid credentials with attempt information
|
|
if (data.remaining !== undefined && data.remaining > 0) {
|
|
setError(
|
|
`${data.message || 'Invalid username or password.'} (${data.remaining} ${data.remaining === 1 ? 'attempt' : 'attempts'} remaining)`,
|
|
);
|
|
} else if (data.locked) {
|
|
setError(data.message || 'Invalid username or password. Account is now locked.');
|
|
} else {
|
|
setError(data.message || 'Invalid username or password');
|
|
}
|
|
// Clear the input fields
|
|
setUsername('');
|
|
setPassword('');
|
|
} else {
|
|
setError(data.message || 'Server error. Please try again.');
|
|
}
|
|
} catch (_err) {
|
|
// Try the old method as fallback
|
|
try {
|
|
const response = await apiClient.fetch('/api/state', {
|
|
headers: {
|
|
Authorization: `Basic ${btoa(`${usernameValue}:${passwordValue}`)}`,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
Accept: 'application/json',
|
|
},
|
|
skipAuth: true,
|
|
});
|
|
|
|
if (response.ok) {
|
|
try {
|
|
sessionStorage.setItem('pulse_auth_user', usernameValue);
|
|
} catch (_storageErr) {
|
|
// Ignore storage issues
|
|
}
|
|
props.onLogin();
|
|
} else if (response.status === 401) {
|
|
setError('Invalid username or password');
|
|
setUsername('');
|
|
setPassword('');
|
|
} else {
|
|
setError('Server error. Please try again.');
|
|
}
|
|
} catch (_fallbackErr) {
|
|
setError('Failed to connect to server');
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Debug logging
|
|
logger.debug('[Login] Render', {
|
|
loadingAuth: loadingAuth(),
|
|
authStatus: authStatus(),
|
|
});
|
|
|
|
const legacyDisableAuth = () => authStatus()?.deprecatedDisableAuth === true;
|
|
const showFirstRunSetup = () =>
|
|
authStatus()?.hasAuthentication === false || legacyDisableAuth();
|
|
|
|
const shouldShowLocalLogin = () => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get('show_local') === 'true') return true;
|
|
return !authStatus()?.hideLocalLogin;
|
|
};
|
|
|
|
return (
|
|
<Show
|
|
when={!loadingAuth()}
|
|
fallback={
|
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900">
|
|
<div class="text-center">
|
|
<div class="animate-spin h-12 w-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
|
|
<p class="text-gray-600 dark:text-gray-400">Checking authentication...</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<Show
|
|
when={showFirstRunSetup()}
|
|
fallback={
|
|
<LoginForm
|
|
{...{
|
|
username,
|
|
setUsername,
|
|
password,
|
|
setPassword,
|
|
rememberMe,
|
|
setRememberMe,
|
|
error,
|
|
loading,
|
|
handleSubmit,
|
|
supportsOIDC,
|
|
startOidcLogin,
|
|
oidcLoading,
|
|
oidcError,
|
|
oidcMessage,
|
|
showLocalLogin: shouldShowLocalLogin(),
|
|
ssoProviders: ssoProviders(),
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
<Suspense
|
|
fallback={
|
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900">
|
|
<div class="text-center">
|
|
<div class="animate-spin h-12 w-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
|
|
<p class="text-gray-600 dark:text-gray-400">Loading setup...</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<SetupWizard
|
|
onComplete={() => window.location.reload()}
|
|
/>
|
|
</Suspense>
|
|
</Show>
|
|
</Show>
|
|
);
|
|
};
|
|
|
|
// Extract login form to separate component for cleaner code
|
|
const LoginForm: Component<{
|
|
username: () => string;
|
|
setUsername: (v: string) => void;
|
|
password: () => string;
|
|
setPassword: (v: string) => void;
|
|
rememberMe: () => boolean;
|
|
setRememberMe: (v: boolean) => void;
|
|
error: () => string;
|
|
loading: () => boolean;
|
|
handleSubmit: (e: Event) => void;
|
|
supportsOIDC: () => boolean;
|
|
startOidcLogin: () => void | Promise<void>;
|
|
oidcLoading: () => boolean;
|
|
oidcError: () => string;
|
|
oidcMessage: () => string;
|
|
showLocalLogin: boolean;
|
|
ssoProviders: SSOProviderInfo[];
|
|
}> = (props) => {
|
|
const {
|
|
username,
|
|
setUsername,
|
|
password,
|
|
setPassword,
|
|
rememberMe,
|
|
setRememberMe,
|
|
error,
|
|
loading,
|
|
handleSubmit,
|
|
supportsOIDC,
|
|
startOidcLogin,
|
|
oidcLoading,
|
|
oidcError,
|
|
oidcMessage,
|
|
showLocalLogin,
|
|
ssoProviders,
|
|
} = props;
|
|
|
|
// Check if we're on the demo server
|
|
const isDemoServer = () => {
|
|
const hostname = window.location.hostname;
|
|
return hostname === 'demo.pulserelay.pro' || hostname.includes('demo.');
|
|
};
|
|
|
|
return (
|
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900 py-12 px-4 sm:px-6 lg:px-8">
|
|
<div class="max-w-md w-full space-y-8">
|
|
{/* Demo Credentials Banner */}
|
|
<Show when={isDemoServer()}>
|
|
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg rounded-lg p-4 shadow-xl border border-blue-200 dark:border-blue-800 animate-fade-in">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-r from-blue-600 to-cyan-600 flex items-center justify-center">
|
|
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1">
|
|
<div class="font-semibold text-sm text-gray-900 dark:text-white">Demo Mode</div>
|
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
Login with <code class="bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 rounded font-mono text-xs">demo</code> / <code class="bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 rounded font-mono text-xs">demo</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<div class="animate-fade-in">
|
|
<div class="flex justify-center mb-8">
|
|
<div class="relative group">
|
|
<div class="absolute -inset-1 bg-gradient-to-r from-blue-600 to-cyan-600 rounded-full blur opacity-25 group-hover:opacity-75 transition duration-1000 group-hover:duration-200 animate-pulse-slow"></div>
|
|
<img
|
|
src="/logo.svg"
|
|
alt="Pulse Logo"
|
|
class="relative w-24 h-24 transform transition duration-500 group-hover:scale-110"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="mt-6 text-center text-4xl font-extrabold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent animate-fade-in delay-100 pb-1">
|
|
Welcome to Pulse
|
|
</h2>
|
|
|
|
<Show when={showLocalLogin}>
|
|
<p class="mt-3 text-center text-sm text-gray-600 dark:text-gray-400 animate-fade-in delay-200">
|
|
Enter your credentials to continue
|
|
</p>
|
|
</Show>
|
|
</div>
|
|
<form
|
|
class="mt-8 space-y-6 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg rounded-lg p-8 shadow-xl animate-slide-up"
|
|
onSubmit={handleSubmit}
|
|
>
|
|
{/* Multi-Provider SSO Section */}
|
|
<Show when={ssoProviders.length > 0}>
|
|
<div class="space-y-3">
|
|
<For each={ssoProviders}>
|
|
{(provider) => (
|
|
<button
|
|
type="button"
|
|
class={`w-full inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-blue-500 text-blue-600 hover:bg-blue-50 transition dark:border-blue-400 dark:text-blue-200 dark:hover:bg-blue-900/40 ${oidcLoading() ? 'opacity-75 cursor-wait' : ''}`}
|
|
disabled={oidcLoading()}
|
|
onClick={() => {
|
|
window.location.href = provider.loginUrl;
|
|
}}
|
|
>
|
|
<span class="inline-flex items-center gap-2">
|
|
<Show
|
|
when={provider.iconUrl}
|
|
fallback={
|
|
provider.type === 'saml' ? (
|
|
<Key class="w-5 h-5" />
|
|
) : (
|
|
<Globe class="w-5 h-5" />
|
|
)
|
|
}
|
|
>
|
|
<img
|
|
src={provider.iconUrl}
|
|
alt=""
|
|
class="w-5 h-5"
|
|
/>
|
|
</Show>
|
|
Continue with {provider.displayName || provider.name}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</For>
|
|
<Show when={oidcError()}>
|
|
<div class="rounded-md bg-red-50 dark:bg-red-900/40 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-600 dark:text-red-300">
|
|
{oidcError()}
|
|
</div>
|
|
</Show>
|
|
<Show when={oidcMessage()}>
|
|
<div class="rounded-md bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 px-3 py-2 text-sm text-green-600 dark:text-green-300">
|
|
{oidcMessage()}
|
|
</div>
|
|
</Show>
|
|
<Show when={showLocalLogin}>
|
|
<div class="flex items-center gap-3 pt-2">
|
|
<span class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
|
<span class="text-xs uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
or
|
|
</span>
|
|
<span class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
|
</div>
|
|
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
|
|
Use your admin credentials to sign in below.
|
|
</p>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
{/* Legacy OIDC fallback (when no multi-provider SSO but legacy OIDC is enabled) */}
|
|
<Show when={ssoProviders.length === 0 && supportsOIDC()}>
|
|
<div class="space-y-3">
|
|
<button
|
|
type="button"
|
|
class={`w-full inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-blue-500 text-blue-600 hover:bg-blue-50 transition dark:border-blue-400 dark:text-blue-200 dark:hover:bg-blue-900/40 ${oidcLoading() ? 'opacity-75 cursor-wait' : ''}`}
|
|
disabled={oidcLoading()}
|
|
onClick={() => startOidcLogin()}
|
|
>
|
|
<Show
|
|
when={!oidcLoading()}
|
|
fallback={
|
|
<span class="inline-flex items-center gap-2">
|
|
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
|
Redirecting…
|
|
</span>
|
|
}
|
|
>
|
|
<span class="inline-flex items-center gap-2">
|
|
<Globe class="w-5 h-5" />
|
|
Continue with Single Sign-On
|
|
</span>
|
|
</Show>
|
|
</button>
|
|
<Show when={oidcError()}>
|
|
<div class="rounded-md bg-red-50 dark:bg-red-900/40 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-600 dark:text-red-300">
|
|
{oidcError()}
|
|
</div>
|
|
</Show>
|
|
<Show when={oidcMessage()}>
|
|
<div class="rounded-md bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 px-3 py-2 text-sm text-green-600 dark:text-green-300">
|
|
{oidcMessage()}
|
|
</div>
|
|
</Show>
|
|
<Show when={showLocalLogin}>
|
|
<div class="flex items-center gap-3 pt-2">
|
|
<span class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
|
<span class="text-xs uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
or
|
|
</span>
|
|
<span class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
|
</div>
|
|
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
|
|
Use your admin credentials to sign in below.
|
|
</p>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
<Show when={showLocalLogin}>
|
|
<div class="space-y-4">
|
|
<div class="relative">
|
|
<label for="username" class="sr-only">
|
|
Username
|
|
</label>
|
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<svg
|
|
class="h-5 w-5 text-gray-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<input
|
|
id="username"
|
|
name="username"
|
|
type="text"
|
|
autocomplete="username"
|
|
required
|
|
class="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
|
placeholder="Username"
|
|
value={username()}
|
|
onInput={(e) => setUsername(e.currentTarget.value)}
|
|
/>
|
|
</div>
|
|
<div class="relative">
|
|
<label for="password" class="sr-only">
|
|
Password
|
|
</label>
|
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<svg
|
|
class="h-5 w-5 text-gray-400"
|
|
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>
|
|
</div>
|
|
<input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
required
|
|
class="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
|
placeholder="Password"
|
|
value={password()}
|
|
onInput={(e) => setPassword(e.currentTarget.value)}
|
|
/>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<input
|
|
id="remember-me"
|
|
name="remember-me"
|
|
type="checkbox"
|
|
checked={rememberMe()}
|
|
onChange={(e) => setRememberMe(e.currentTarget.checked)}
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer dark:border-gray-600 dark:bg-gray-700"
|
|
/>
|
|
<label
|
|
for="remember-me"
|
|
class="ml-2 block text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
|
>
|
|
Remember me
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<Show when={error()}>
|
|
<div
|
|
class={`rounded-md p-4 ${error().includes('locked')
|
|
? 'bg-orange-50 dark:bg-orange-900/20'
|
|
: 'bg-red-50 dark:bg-red-900/20'
|
|
}`}
|
|
>
|
|
<div class="flex">
|
|
<div class="flex-shrink-0">
|
|
<Show
|
|
when={error().includes('locked')}
|
|
fallback={
|
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
}
|
|
>
|
|
<svg class="h-5 w-5 text-orange-400" viewBox="0 0 20 20" fill="currentColor">
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</Show>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p
|
|
class={`text-sm ${error().includes('locked')
|
|
? 'text-orange-800 dark:text-orange-200'
|
|
: 'text-red-800 dark:text-red-200'
|
|
}`}
|
|
>
|
|
{error()}
|
|
</p>
|
|
<Show when={error().includes('locked') && error().includes('minute')}>
|
|
<p class="text-xs mt-1 text-orange-700 dark:text-orange-300">
|
|
Lockouts automatically expire after the specified time. If you need immediate
|
|
access, contact your administrator.
|
|
</p>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<div>
|
|
<button
|
|
type="submit"
|
|
disabled={loading()}
|
|
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transform transition hover:scale-105 shadow-lg"
|
|
>
|
|
<Show when={loading()}>
|
|
<svg
|
|
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
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>
|
|
</Show>
|
|
<Show when={loading()} fallback="Sign in to Pulse">
|
|
Authenticating...
|
|
</Show>
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|