1128 lines
44 KiB
JavaScript
1128 lines
44 KiB
JavaScript
/**
|
|
* OxiCloud Authentication JavaScript
|
|
* Handles login, registration, and admin setup
|
|
*/
|
|
|
|
// API endpoints
|
|
const API_URL = '/api/auth';
|
|
const LOGIN_ENDPOINT = `${API_URL}/login`;
|
|
const REGISTER_ENDPOINT = `${API_URL}/register`;
|
|
const ME_ENDPOINT = `${API_URL}/me`;
|
|
const REFRESH_ENDPOINT = `${API_URL}/refresh`;
|
|
|
|
// Storage keys
|
|
const TOKEN_KEY = 'oxicloud_token';
|
|
const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token';
|
|
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
|
|
const USER_DATA_KEY = 'oxicloud_user';
|
|
const LOCALE_KEY = 'oxicloud-locale';
|
|
const FIRST_RUN_KEY = 'oxicloud_first_run_completed';
|
|
|
|
// Language selector texts (used before i18n is loaded)
|
|
const LANGUAGE_TEXTS = {
|
|
en: {
|
|
title: 'Welcome to OxiCloud',
|
|
subtitle: 'Please select your language',
|
|
continue: 'Continue',
|
|
autodetected: 'We detected your language',
|
|
moreLanguages: 'More languages...',
|
|
modalTitle: 'Select language',
|
|
searchPlaceholder: 'Search language...'
|
|
},
|
|
es: {
|
|
title: 'Bienvenido a OxiCloud',
|
|
subtitle: 'Por favor, selecciona tu idioma',
|
|
continue: 'Continuar',
|
|
autodetected: 'Hemos detectado tu idioma',
|
|
moreLanguages: 'More languages...',
|
|
modalTitle: 'Seleccionar idioma',
|
|
searchPlaceholder: 'Buscar idioma...'
|
|
},
|
|
zh: {
|
|
title: '欢迎使用 OxiCloud',
|
|
subtitle: '请选择您的语言',
|
|
continue: '继续',
|
|
autodetected: '我们检测到了您的语言',
|
|
moreLanguages: '更多语言...',
|
|
modalTitle: '选择语言',
|
|
searchPlaceholder: '搜索语言...'
|
|
},
|
|
fa: {
|
|
title: 'به OxiCloud خوش آمدید',
|
|
subtitle: 'لطفا زبان خود را انتخاب کنید',
|
|
continue: 'ادامه',
|
|
autodetected: 'زبان شما شناسایی شد',
|
|
moreLanguages: 'زبانهای بیشتر...',
|
|
modalTitle: 'انتخاب زبان',
|
|
searchPlaceholder: 'جستجوی زبان...'
|
|
}
|
|
};
|
|
|
|
// Complete language registry — add new languages here, they'll appear automatically
|
|
// `popular: true` languages show as cards on the main screen, the rest in the modal
|
|
const ALL_LANGUAGES = [
|
|
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧', popular: true },
|
|
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', popular: true },
|
|
{ code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', popular: true },
|
|
{ code: 'fa', name: 'Persian', nativeName: 'فارسی', flag: '🇮🇷', popular: true },
|
|
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', popular: true },
|
|
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', popular: true },
|
|
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷', popular: true },
|
|
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', popular: true },
|
|
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', popular: false },
|
|
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵', popular: false },
|
|
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷', popular: false },
|
|
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', popular: false },
|
|
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', popular: false },
|
|
{ code: 'tr', name: 'Turkish', nativeName: 'Türkçe', flag: '🇹🇷', popular: false },
|
|
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands', flag: '🇳🇱', popular: false },
|
|
{ code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱', popular: false },
|
|
{ code: 'sv', name: 'Swedish', nativeName: 'Svenska', flag: '🇸🇪', popular: false },
|
|
{ code: 'da', name: 'Danish', nativeName: 'Dansk', flag: '🇩🇰', popular: false },
|
|
{ code: 'fi', name: 'Finnish', nativeName: 'Suomi', flag: '🇫🇮', popular: false },
|
|
{ code: 'no', name: 'Norwegian', nativeName: 'Norsk', flag: '🇳🇴', popular: false },
|
|
{ code: 'uk', name: 'Ukrainian', nativeName: 'Українська', flag: '🇺🇦', popular: false },
|
|
{ code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿', popular: false },
|
|
{ code: 'el', name: 'Greek', nativeName: 'Ελληνικά', flag: '🇬🇷', popular: false },
|
|
{ code: 'he', name: 'Hebrew', nativeName: 'עברית', flag: '🇮🇱', popular: false },
|
|
{ code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭', popular: false },
|
|
{ code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt', flag: '🇻🇳', popular: false },
|
|
{ code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia', flag: '🇮🇩', popular: false },
|
|
{ code: 'ms', name: 'Malay', nativeName: 'Bahasa Melayu', flag: '🇲🇾', popular: false },
|
|
{ code: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴', popular: false },
|
|
{ code: 'hu', name: 'Hungarian', nativeName: 'Magyar', flag: '🇭🇺', popular: false },
|
|
{ code: 'ca', name: 'Catalan', nativeName: 'Català', flag: '🏴', popular: false },
|
|
{ code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: '🏴', popular: false },
|
|
{ code: 'gl', name: 'Galician', nativeName: 'Galego', flag: '🏴', popular: false },
|
|
];
|
|
|
|
// Check if this is a first run (no locale saved)
|
|
function isFirstRun() {
|
|
return !localStorage.getItem(LOCALE_KEY);
|
|
}
|
|
|
|
// Check system status from the server
|
|
async function checkSystemStatus() {
|
|
try {
|
|
const response = await fetch('/api/auth/status');
|
|
if (!response.ok) {
|
|
console.warn('Could not check system status, assuming initialized');
|
|
return { initialized: true, admin_count: 1, registration_allowed: true };
|
|
}
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Error checking system status:', error);
|
|
return { initialized: true, admin_count: 1, registration_allowed: true };
|
|
}
|
|
}
|
|
|
|
// Detect user's browser language and return the best matching language from ALL_LANGUAGES
|
|
function detectBrowserLanguage() {
|
|
const browserLangs = navigator.languages || [navigator.language || navigator.userLanguage || 'en'];
|
|
for (const bl of browserLangs) {
|
|
const code = bl.substring(0, 2).toLowerCase();
|
|
const match = ALL_LANGUAGES.find(l => l.code === code);
|
|
if (match) return match;
|
|
}
|
|
return ALL_LANGUAGES[0]; // fallback to English
|
|
}
|
|
|
|
// Build a language option element (card style)
|
|
function buildLanguageCard(lang, isSelected) {
|
|
const item = document.createElement('div');
|
|
item.className = 'lang-picker-item' + (isSelected ? ' selected' : '');
|
|
item.setAttribute('data-lang', lang.code);
|
|
item.setAttribute('role', 'option');
|
|
item.setAttribute('aria-selected', isSelected);
|
|
item.innerHTML = `
|
|
<span class="lang-picker-item-flag">${lang.flag}</span>
|
|
<span class="lang-picker-item-name">${lang.nativeName}</span>
|
|
<span class="lang-picker-item-english">${lang.name}</span>
|
|
${isSelected ? '<i class="fas fa-check lang-picker-item-check"></i>' : ''}
|
|
`;
|
|
return item;
|
|
}
|
|
|
|
// Initialize language selector panel with compact dropdown approach
|
|
function initLanguageSelector() {
|
|
const languagePanel = document.getElementById('language-panel');
|
|
const continueBtn = document.getElementById('language-continue');
|
|
const picker = document.getElementById('lang-picker');
|
|
const pickerSelected = document.getElementById('lang-picker-selected');
|
|
const pickerDropdown = document.getElementById('lang-picker-dropdown');
|
|
const pickerList = document.getElementById('lang-picker-list');
|
|
const pickerFlag = document.getElementById('lang-picker-flag');
|
|
const pickerName = document.getElementById('lang-picker-name');
|
|
const searchInput = document.getElementById('lang-picker-search-input');
|
|
|
|
if (!languagePanel || !picker) return;
|
|
|
|
// --- Auto-detect browser language ---
|
|
const detected = detectBrowserLanguage();
|
|
let selectedLanguage = detected.code;
|
|
|
|
// Update the selected box with detected language
|
|
pickerFlag.textContent = detected.flag;
|
|
pickerName.textContent = detected.nativeName;
|
|
updateLanguagePanelTexts(selectedLanguage);
|
|
|
|
// Render dropdown list
|
|
function renderDropdownList(filter = '') {
|
|
pickerList.innerHTML = '';
|
|
const filterLower = filter.toLowerCase();
|
|
|
|
const filtered = ALL_LANGUAGES.filter(lang => {
|
|
if (!filter) return true;
|
|
return lang.name.toLowerCase().includes(filterLower) ||
|
|
lang.nativeName.toLowerCase().includes(filterLower) ||
|
|
lang.code.toLowerCase().includes(filterLower);
|
|
});
|
|
|
|
if (filtered.length === 0) {
|
|
pickerList.innerHTML = '<div class="lang-picker-empty">—</div>';
|
|
return;
|
|
}
|
|
|
|
filtered.forEach(lang => {
|
|
const item = buildLanguageCard(lang, lang.code === selectedLanguage);
|
|
item.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
selectedLanguage = lang.code;
|
|
pickerFlag.textContent = lang.flag;
|
|
pickerName.textContent = lang.nativeName;
|
|
updateLanguagePanelTexts(lang.code);
|
|
closePicker();
|
|
renderDropdownList('');
|
|
});
|
|
pickerList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function openPicker() {
|
|
picker.classList.add('open');
|
|
pickerSelected.setAttribute('aria-expanded', 'true');
|
|
renderDropdownList('');
|
|
if (searchInput) {
|
|
searchInput.value = '';
|
|
setTimeout(() => searchInput.focus(), 50);
|
|
}
|
|
// Scroll active item into view
|
|
setTimeout(() => {
|
|
const active = pickerList.querySelector('.lang-picker-item.selected');
|
|
if (active) active.scrollIntoView({ block: 'nearest' });
|
|
}, 60);
|
|
}
|
|
|
|
function closePicker() {
|
|
picker.classList.remove('open');
|
|
pickerSelected.setAttribute('aria-expanded', 'false');
|
|
if (searchInput) searchInput.value = '';
|
|
}
|
|
|
|
// Toggle dropdown
|
|
pickerSelected.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (picker.classList.contains('open')) {
|
|
closePicker();
|
|
} else {
|
|
openPicker();
|
|
}
|
|
});
|
|
|
|
// Keyboard support
|
|
pickerSelected.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
if (picker.classList.contains('open')) closePicker(); else openPicker();
|
|
} else if (e.key === 'Escape') {
|
|
closePicker();
|
|
}
|
|
});
|
|
|
|
// Search input
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', () => renderDropdownList(searchInput.value));
|
|
searchInput.addEventListener('click', (e) => e.stopPropagation());
|
|
}
|
|
|
|
// Close when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!picker.contains(e.target)) closePicker();
|
|
});
|
|
|
|
// --- Continue button ---
|
|
continueBtn.addEventListener('click', async () => {
|
|
if (!selectedLanguage) return;
|
|
|
|
// Save locale preference
|
|
localStorage.setItem(LOCALE_KEY, selectedLanguage);
|
|
localStorage.setItem(FIRST_RUN_KEY, 'true');
|
|
|
|
// Update i18n if available
|
|
if (window.i18n && window.i18n.setLocale) {
|
|
await window.i18n.setLocale(selectedLanguage);
|
|
}
|
|
|
|
// Hide language panel
|
|
languagePanel.style.display = 'none';
|
|
|
|
// Check system status to determine which panel to show
|
|
const systemStatus = await checkSystemStatus();
|
|
console.log('System status after language selection:', systemStatus);
|
|
|
|
if (!systemStatus.initialized) {
|
|
console.log('No admin exists, showing admin setup panel');
|
|
document.getElementById('login-panel').style.display = 'none';
|
|
document.getElementById('register-panel').style.display = 'none';
|
|
document.getElementById('admin-setup-panel').style.display = 'block';
|
|
|
|
const backToLoginLink = document.getElementById('back-to-login');
|
|
if (backToLoginLink) {
|
|
backToLoginLink.parentElement.style.display = 'none';
|
|
}
|
|
} else {
|
|
document.getElementById('login-panel').style.display = 'block';
|
|
// Configure OIDC login UI if SSO is enabled
|
|
await configureOidcLoginUI();
|
|
}
|
|
|
|
// Translate the page with new locale
|
|
if (window.i18n && window.i18n.translatePage) {
|
|
window.i18n.translatePage();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update language panel texts based on selected language
|
|
function updateLanguagePanelTexts(lang) {
|
|
const texts = LANGUAGE_TEXTS[lang] || LANGUAGE_TEXTS.en;
|
|
const titleEl = document.getElementById('language-title');
|
|
const subtitleEl = document.getElementById('language-subtitle');
|
|
const continueBtn = document.getElementById('language-continue');
|
|
const searchInput = document.getElementById('lang-picker-search-input');
|
|
|
|
if (titleEl) titleEl.textContent = texts.title;
|
|
if (subtitleEl) subtitleEl.textContent = texts.subtitle;
|
|
if (continueBtn) continueBtn.textContent = texts.continue;
|
|
if (searchInput) searchInput.placeholder = texts.searchPlaceholder;
|
|
}
|
|
|
|
// Show appropriate panel based on system status and first run
|
|
async function showInitialPanel() {
|
|
const languagePanel = document.getElementById('language-panel');
|
|
const loginPanel = document.getElementById('login-panel');
|
|
const adminSetupPanel = document.getElementById('admin-setup-panel');
|
|
const registerPanel = document.getElementById('register-panel');
|
|
|
|
if (!languagePanel || !loginPanel) return;
|
|
|
|
// ALWAYS check if this is user's first run (language selection) FIRST
|
|
// Language selection should happen before anything else
|
|
if (isFirstRun()) {
|
|
// First run - show language selector first
|
|
// After language is selected, the continue button handler will check system status
|
|
console.log('First run - showing language selector');
|
|
languagePanel.style.display = 'block';
|
|
loginPanel.style.display = 'none';
|
|
registerPanel.style.display = 'none';
|
|
adminSetupPanel.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Language already selected - now check system status
|
|
const systemStatus = await checkSystemStatus();
|
|
console.log('System status:', systemStatus);
|
|
|
|
if (!systemStatus.initialized) {
|
|
// No admin exists - this is a fresh install, show admin setup
|
|
console.log('Fresh install detected - showing admin setup');
|
|
languagePanel.style.display = 'none';
|
|
loginPanel.style.display = 'none';
|
|
registerPanel.style.display = 'none';
|
|
adminSetupPanel.style.display = 'block';
|
|
|
|
// Hide the "Already set up? Sign in" link since there's no admin yet
|
|
const backToLoginLink = document.getElementById('back-to-login');
|
|
if (backToLoginLink) {
|
|
backToLoginLink.parentElement.style.display = 'none';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// System is initialized - show login panel
|
|
languagePanel.style.display = 'none';
|
|
loginPanel.style.display = 'block';
|
|
registerPanel.style.display = 'none';
|
|
adminSetupPanel.style.display = 'none';
|
|
|
|
// Hide the admin setup link if admin already exists
|
|
const showAdminSetupLink = document.getElementById('show-admin-setup');
|
|
if (showAdminSetupLink && systemStatus.admin_count > 0) {
|
|
showAdminSetupLink.parentElement.style.display = 'none';
|
|
}
|
|
|
|
// Check for OIDC/SSO configuration and update login panel accordingly
|
|
await configureOidcLoginUI();
|
|
}
|
|
|
|
// Fetch OIDC provider info and configure the login UI
|
|
async function configureOidcLoginUI() {
|
|
try {
|
|
const response = await fetch('/api/auth/oidc/providers');
|
|
if (!response.ok) return;
|
|
|
|
const oidcInfo = await response.json();
|
|
if (!oidcInfo.enabled) return;
|
|
|
|
const oidcSection = document.getElementById('oidc-login-section');
|
|
const oidcBtn = document.getElementById('oidc-login-btn');
|
|
const loginForm = document.getElementById('login-form');
|
|
const authDivider = document.getElementById('auth-divider');
|
|
const showRegisterToggle = document.getElementById('show-register');
|
|
|
|
if (!oidcSection || !oidcBtn) return;
|
|
|
|
// Update button text with provider name
|
|
const btnTextEl = oidcBtn.querySelector('span');
|
|
if (btnTextEl && oidcInfo.provider_name) {
|
|
const template = (window.i18n && window.i18n.t)
|
|
? window.i18n.t('auth.sso_login_provider')
|
|
: 'Sign in with {{provider}}';
|
|
btnTextEl.textContent = template.replace('{{provider}}', oidcInfo.provider_name);
|
|
}
|
|
|
|
// Redirect to OIDC authorize endpoint on click
|
|
oidcBtn.addEventListener('click', () => {
|
|
window.location.href = oidcInfo.authorize_endpoint;
|
|
});
|
|
|
|
if (!oidcInfo.password_login_enabled) {
|
|
// OIDC-only mode: hide password form and divider, show only SSO button
|
|
if (loginForm) loginForm.style.display = 'none';
|
|
if (authDivider) authDivider.style.display = 'none';
|
|
if (showRegisterToggle) showRegisterToggle.parentElement.style.display = 'none';
|
|
oidcSection.style.display = 'block';
|
|
} else {
|
|
// Both password and OIDC enabled: show divider + SSO button
|
|
oidcSection.style.display = 'block';
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch OIDC provider info:', err);
|
|
}
|
|
}
|
|
|
|
// DOM elements
|
|
let loginPanel, registerPanel, adminSetupPanel, languagePanel;
|
|
let loginForm, registerForm, adminSetupForm;
|
|
let loginError, registerError, registerSuccess, adminSetupError;
|
|
|
|
// Initialize DOM elements only if we're on the login page
|
|
function initLoginElements() {
|
|
// Check if we're on the login page
|
|
if (!document.getElementById('login-form')) {
|
|
console.log('Not on login page, skipping element initialization');
|
|
return false;
|
|
}
|
|
|
|
languagePanel = document.getElementById('language-panel');
|
|
loginPanel = document.getElementById('login-panel');
|
|
registerPanel = document.getElementById('register-panel');
|
|
adminSetupPanel = document.getElementById('admin-setup-panel');
|
|
|
|
loginForm = document.getElementById('login-form');
|
|
registerForm = document.getElementById('register-form');
|
|
adminSetupForm = document.getElementById('admin-setup-form');
|
|
|
|
loginError = document.getElementById('login-error');
|
|
registerError = document.getElementById('register-error');
|
|
registerSuccess = document.getElementById('register-success');
|
|
adminSetupError = document.getElementById('admin-setup-error');
|
|
|
|
// Initialize language selector
|
|
initLanguageSelector();
|
|
|
|
// Panel toggles
|
|
document.getElementById('show-register').addEventListener('click', () => {
|
|
loginPanel.style.display = 'none';
|
|
registerPanel.style.display = 'block';
|
|
adminSetupPanel.style.display = 'none';
|
|
});
|
|
|
|
document.getElementById('show-login').addEventListener('click', () => {
|
|
loginPanel.style.display = 'block';
|
|
registerPanel.style.display = 'none';
|
|
adminSetupPanel.style.display = 'none';
|
|
});
|
|
|
|
document.getElementById('show-admin-setup').addEventListener('click', () => {
|
|
loginPanel.style.display = 'none';
|
|
registerPanel.style.display = 'none';
|
|
adminSetupPanel.style.display = 'block';
|
|
});
|
|
|
|
document.getElementById('back-to-login').addEventListener('click', () => {
|
|
loginPanel.style.display = 'block';
|
|
registerPanel.style.display = 'none';
|
|
adminSetupPanel.style.display = 'none';
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
// Initialize login elements if on login page
|
|
const isLoginPage = initLoginElements();
|
|
|
|
// Check if we already have a valid token
|
|
let authInitialized = false;
|
|
|
|
// EMERGENCY HANDLER: Detect if page is being loaded from a redirect loop
|
|
// and clear auth data to break the loop
|
|
(() => {
|
|
// Check if we're being redirected in a loop
|
|
const refreshAttempts = parseInt(localStorage.getItem('refresh_attempts') || '0');
|
|
const redirectSource = new URLSearchParams(window.location.search).get('source');
|
|
|
|
// Case 1: High refresh attempts
|
|
if (refreshAttempts > 3) {
|
|
console.error('EMERGENCY: Detected severe token refresh loop. Cleaning all auth data.');
|
|
localStorage.clear(); // Full localStorage clear to ensure we break the loop
|
|
sessionStorage.clear();
|
|
localStorage.setItem('emergency_clean', 'true');
|
|
|
|
// Store timestamp of the cleanup for stability
|
|
localStorage.setItem('last_emergency_clean', Date.now().toString());
|
|
|
|
// No alert to avoid overwhelming the user if this happens multiple times
|
|
}
|
|
|
|
// Case 2: We were redirected from app due to auth issues
|
|
if (redirectSource === 'app') {
|
|
console.log('Detected redirect from app, ensuring clean auth state');
|
|
// Clear only auth-related data to ensure a clean login
|
|
localStorage.removeItem('oxicloud_token');
|
|
localStorage.removeItem('oxicloud_refresh_token');
|
|
localStorage.removeItem('oxicloud_token_expiry');
|
|
|
|
// Reset counters
|
|
sessionStorage.removeItem('redirect_count');
|
|
localStorage.setItem('refresh_attempts', '0');
|
|
}
|
|
|
|
// Case 3: Multiple redirects in short time
|
|
const lastCleanup = parseInt(localStorage.getItem('last_emergency_clean') || '0');
|
|
const timeSinceCleanup = Date.now() - lastCleanup;
|
|
|
|
if (lastCleanup > 0 && timeSinceCleanup < 10000) { // Less than 10 seconds
|
|
console.warn('Multiple auth problems in short time, clearing auth data');
|
|
localStorage.removeItem('oxicloud_token');
|
|
localStorage.removeItem('oxicloud_refresh_token');
|
|
localStorage.removeItem('oxicloud_token_expiry');
|
|
}
|
|
})();
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// CRITICAL: Stop any potential redirect loops by handling browser throttling
|
|
if (document.visibilityState === 'hidden') {
|
|
console.warn('Page hidden, avoiding potential navigation loop');
|
|
return;
|
|
}
|
|
|
|
// Check if we're on the login page
|
|
if (!document.getElementById('login-form')) {
|
|
console.log('Not on login page, skipping auth check');
|
|
return;
|
|
}
|
|
|
|
// --- OIDC exchange code handling (fallback if landing on login page) ---
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const oidcCode = urlParams.get('oidc_code');
|
|
|
|
if (oidcCode) {
|
|
console.log('OIDC exchange code detected on login page, exchanging...');
|
|
(async () => {
|
|
try {
|
|
const resp = await fetch('/api/auth/oidc/exchange', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code: oidcCode })
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
console.error('OIDC exchange failed:', resp.status);
|
|
return; // Fall through to normal login page
|
|
}
|
|
|
|
const data = await resp.json();
|
|
const token = data.access_token || data.token;
|
|
const refreshToken = data.refresh_token || data.refreshToken;
|
|
|
|
if (token) {
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
|
|
|
// Parse JWT expiry
|
|
const tokenParts = token.split('.');
|
|
if (tokenParts.length === 3) {
|
|
try {
|
|
const payload = JSON.parse(atob(tokenParts[1]));
|
|
if (payload.exp) {
|
|
const expiryDate = new Date(payload.exp * 1000);
|
|
if (!isNaN(expiryDate.getTime())) {
|
|
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString());
|
|
}
|
|
}
|
|
} catch (e) { /* ignore parse errors */ }
|
|
}
|
|
|
|
if (data.user) {
|
|
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
|
|
}
|
|
|
|
// Redirect to main app
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.error('OIDC exchange error:', err);
|
|
}
|
|
})();
|
|
return; // Don't initialize login page while exchanging
|
|
}
|
|
|
|
if (authInitialized) {
|
|
console.log('Auth already initialized, skipping');
|
|
return;
|
|
}
|
|
authInitialized = true;
|
|
|
|
// Show appropriate panel (language selector on first run, login otherwise, or admin setup if no admin)
|
|
// This is async so we call it and let it run
|
|
showInitialPanel().then(() => {
|
|
console.log('Initial panel shown based on system status');
|
|
}).catch(err => {
|
|
console.error('Error showing initial panel:', err);
|
|
});
|
|
|
|
// Always clear counters when loading the login page
|
|
// to ensure we don't get trapped in a loop
|
|
console.log('Login page loaded, clearing all counters');
|
|
sessionStorage.removeItem('redirect_count');
|
|
localStorage.removeItem('refresh_attempts');
|
|
|
|
(async () => {
|
|
try {
|
|
// First check if the token is valid
|
|
const token = localStorage.getItem(TOKEN_KEY);
|
|
const tokenExpiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
|
|
|
|
if (!token) {
|
|
console.log('No token found, user needs to login');
|
|
// Clear any stale data
|
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
localStorage.removeItem(USER_DATA_KEY);
|
|
return; // Stay on login page
|
|
}
|
|
|
|
// Check if token expiry is valid and not expired
|
|
try {
|
|
const expiryDate = new Date(tokenExpiry);
|
|
if (!isNaN(expiryDate.getTime()) && expiryDate > new Date()) {
|
|
console.log(`Token valid until ${expiryDate.toLocaleString()}`);
|
|
// Token still valid, redirect to main app
|
|
redirectToMainApp();
|
|
return;
|
|
} else {
|
|
console.log('Token expired or invalid date, attempting refresh');
|
|
}
|
|
} catch (dateError) {
|
|
console.error('Error parsing token expiry date:', dateError);
|
|
// Continue to refresh attempt
|
|
}
|
|
|
|
// Token expired, try to refresh
|
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
if (refreshToken) {
|
|
try {
|
|
console.log('Attempting to refresh expired token');
|
|
await refreshAuthToken(refreshToken);
|
|
console.log('Token refresh successful, redirecting to app');
|
|
redirectToMainApp();
|
|
} catch (error) {
|
|
// Refresh failed, continue with login page
|
|
console.log('Token refresh failed, user needs to login again:', error.message);
|
|
// Clear any stale auth data
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
localStorage.removeItem(USER_DATA_KEY);
|
|
}
|
|
} else {
|
|
console.log('No refresh token found, user needs to login');
|
|
}
|
|
|
|
// Check if admin account exists (customize this as needed)
|
|
const isFirstRun = await checkFirstRun();
|
|
if (isFirstRun) {
|
|
loginPanel.style.display = 'none';
|
|
registerPanel.style.display = 'none';
|
|
adminSetupPanel.style.display = 'block';
|
|
}
|
|
} catch (error) {
|
|
console.error('Authentication check failed:', error);
|
|
}
|
|
})();
|
|
});
|
|
|
|
// Login form submission
|
|
if (isLoginPage && loginForm) {
|
|
loginForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
// Clear previous errors
|
|
loginError.style.display = 'none';
|
|
|
|
const username = document.getElementById('login-username').value;
|
|
const password = document.getElementById('login-password').value;
|
|
|
|
try {
|
|
const data = await login(username, password);
|
|
|
|
// Store auth data
|
|
console.log("Login response:", data); // Log the response for debugging
|
|
|
|
// Use the correct field names from our API response
|
|
const token = data.access_token || data.token;
|
|
const refreshToken = data.refresh_token || data.refreshToken;
|
|
|
|
if (!token) {
|
|
throw new Error('Server did not return an access token');
|
|
}
|
|
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
|
|
|
// Extract expiration date from the JWT token
|
|
let parsedExpiry = false;
|
|
const tokenParts = token.split('.');
|
|
if (tokenParts.length === 3) {
|
|
try {
|
|
const payload = JSON.parse(atob(tokenParts[1]));
|
|
if (payload.exp) {
|
|
// payload.exp is in seconds since epoch
|
|
const expiryDate = new Date(payload.exp * 1000);
|
|
|
|
// Verify the date is valid
|
|
if (!isNaN(expiryDate.getTime())) {
|
|
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString());
|
|
parsedExpiry = true;
|
|
console.log(`Token expires on: ${expiryDate.toLocaleString()}`);
|
|
} else {
|
|
console.warn('Invalid expiry date in token:', payload.exp);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Error parsing JWT token:', e);
|
|
}
|
|
}
|
|
|
|
// If we couldn't parse the expiry, set a default (30 days)
|
|
if (!parsedExpiry) {
|
|
console.log('Setting default token expiry (30 days)');
|
|
const expiryTime = new Date();
|
|
expiryTime.setDate(expiryTime.getDate() + 30); // 30 days instead of 1 hour
|
|
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
|
|
}
|
|
|
|
// Reset redirect counter on successful login
|
|
sessionStorage.removeItem('redirect_count');
|
|
|
|
// Fetch and store user data from the response
|
|
if (data.user) {
|
|
console.log("Storing user data from server");
|
|
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
|
|
} else {
|
|
console.warn("Server did not return user data — will fetch from /me endpoint after redirect");
|
|
}
|
|
|
|
// Redirect to main app
|
|
redirectToMainApp();
|
|
} catch (error) {
|
|
loginError.textContent = error.message || 'Error logging in';
|
|
loginError.style.display = 'block';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Register form submission
|
|
if (isLoginPage && registerForm) {
|
|
registerForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
// Clear previous messages
|
|
registerError.style.display = 'none';
|
|
registerSuccess.style.display = 'none';
|
|
|
|
const username = document.getElementById('register-username').value;
|
|
const email = document.getElementById('register-email').value;
|
|
const password = document.getElementById('register-password').value;
|
|
const confirmPassword = document.getElementById('register-password-confirm').value;
|
|
|
|
// Validate passwords match
|
|
if (password !== confirmPassword) {
|
|
const errorMsg = window.i18n ? window.i18n.t('auth.passwords_mismatch') : 'Passwords do not match';
|
|
registerError.textContent = errorMsg;
|
|
registerError.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await register(username, email, password);
|
|
|
|
// Show success message
|
|
const successMsg = window.i18n ? window.i18n.t('auth.account_success') : 'Account created successfully! You can now log in.';
|
|
registerSuccess.textContent = successMsg;
|
|
registerSuccess.style.display = 'block';
|
|
|
|
// Clear form
|
|
registerForm.reset();
|
|
|
|
// Switch to login panel after 2 seconds
|
|
setTimeout(() => {
|
|
loginPanel.style.display = 'block';
|
|
registerPanel.style.display = 'none';
|
|
}, 2000);
|
|
} catch (error) {
|
|
const errorMsg = window.i18n ? window.i18n.t('auth.admin_create_error') : 'Error registering account';
|
|
registerError.textContent = error.message || errorMsg;
|
|
registerError.style.display = 'block';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Admin setup form submission
|
|
if (isLoginPage && adminSetupForm) {
|
|
adminSetupForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
// Clear previous errors/success messages
|
|
adminSetupError.style.display = 'none';
|
|
const adminSetupSuccess = document.getElementById('admin-setup-success');
|
|
if (adminSetupSuccess) adminSetupSuccess.style.display = 'none';
|
|
|
|
const email = document.getElementById('admin-email').value;
|
|
const password = document.getElementById('admin-password').value;
|
|
const confirmPassword = document.getElementById('admin-password-confirm').value;
|
|
|
|
// Validate passwords match
|
|
if (password !== confirmPassword) {
|
|
const errorMsg = window.i18n ? window.i18n.t('auth.passwords_mismatch') : 'Passwords do not match';
|
|
adminSetupError.textContent = errorMsg;
|
|
adminSetupError.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Register admin account
|
|
const data = await register('admin', email, password, 'admin');
|
|
|
|
// Show success message in the GUI instead of alert
|
|
const successMsg = window.i18n ? window.i18n.t('auth.admin_success') : 'Admin account created successfully! You can now log in.';
|
|
|
|
if (adminSetupSuccess) {
|
|
adminSetupSuccess.textContent = successMsg;
|
|
adminSetupSuccess.style.display = 'block';
|
|
}
|
|
|
|
// Wait 2 seconds then switch to login panel
|
|
setTimeout(() => {
|
|
loginPanel.style.display = 'block';
|
|
adminSetupPanel.style.display = 'none';
|
|
if (adminSetupSuccess) adminSetupSuccess.style.display = 'none';
|
|
}, 2000);
|
|
|
|
} catch (error) {
|
|
const errorMsg = window.i18n ? window.i18n.t('auth.admin_create_error') : 'Error creating admin account';
|
|
adminSetupError.textContent = error.message || errorMsg;
|
|
adminSetupError.style.display = 'block';
|
|
}
|
|
});
|
|
}
|
|
|
|
// API Functions
|
|
|
|
/**
|
|
* Login with username and password
|
|
*/
|
|
async function login(username, password) {
|
|
try {
|
|
console.log(`Attempting to login with username: ${username}`);
|
|
|
|
// Add better error handling with timeout
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
|
|
|
const response = await fetch(LOGIN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ username, password }),
|
|
signal: controller.signal
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
console.log(`Login response status: ${response.status}`);
|
|
|
|
// Handle both successful and error responses
|
|
if (!response.ok) {
|
|
try {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Authentication failed');
|
|
} catch (jsonError) {
|
|
// If the error response is not valid JSON
|
|
throw new Error(`Authentication error (${response.status}): ${response.statusText}`);
|
|
}
|
|
}
|
|
|
|
// Parse the JSON response
|
|
try {
|
|
const data = await response.json();
|
|
console.log("Login successful, received data");
|
|
return data;
|
|
} catch (jsonError) {
|
|
console.error('Error parsing login response:', jsonError);
|
|
throw new Error('Error processing server response');
|
|
}
|
|
} catch (error) {
|
|
console.error('Login error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a new user
|
|
*/
|
|
async function register(username, email, password, role = 'user') {
|
|
try {
|
|
console.log(`Attempting to register user: ${username}`);
|
|
|
|
const response = await fetch(REGISTER_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ username, email, password, role })
|
|
});
|
|
|
|
console.log(`Registration response status: ${response.status}`);
|
|
|
|
// Handle both successful and error responses
|
|
if (!response.ok) {
|
|
try {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Registration error');
|
|
} catch (jsonError) {
|
|
// If the error response is not valid JSON
|
|
throw new Error(`Registration error (${response.status}): ${response.statusText}`);
|
|
}
|
|
}
|
|
|
|
// Parse the JSON response
|
|
try {
|
|
const data = await response.json();
|
|
console.log("Registration successful, received data");
|
|
return data;
|
|
} catch (jsonError) {
|
|
console.error('Error parsing registration response:', jsonError);
|
|
throw new Error('Error processing server response');
|
|
}
|
|
} catch (error) {
|
|
console.error('Registration error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch current user data
|
|
*/
|
|
async function fetchUserData(token) {
|
|
try {
|
|
const response = await fetch(ME_ENDPOINT, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Error fetching user data');
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Error fetching user data:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh authentication token - MAJOR CHANGE: Reduced functionality to break token loop
|
|
*/
|
|
async function refreshAuthToken(refreshToken) {
|
|
try {
|
|
// Check if we're in a refresh loop
|
|
const refreshAttempts = parseInt(localStorage.getItem('refresh_attempts') || '0');
|
|
localStorage.setItem('refresh_attempts', (refreshAttempts + 1).toString());
|
|
|
|
if (refreshAttempts > 3) {
|
|
console.error('Refresh token loop detected, clearing all auth data');
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
localStorage.removeItem(USER_DATA_KEY);
|
|
localStorage.removeItem('refresh_attempts');
|
|
sessionStorage.removeItem('redirect_count');
|
|
throw new Error('Too many refresh attempts, forcing login');
|
|
}
|
|
|
|
if (!refreshToken) {
|
|
throw new Error('No refresh token available');
|
|
}
|
|
|
|
console.log("Attempting to refresh token");
|
|
|
|
// Timeout for safety
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
|
|
const response = await fetch(REFRESH_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
signal: controller.signal
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
console.warn(`Refresh token failed with status: ${response.status}`);
|
|
throw new Error(`Token refresh failed: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log("Refresh token response:", data);
|
|
|
|
// Default expiry if we can't extract from token (30 days)
|
|
const expiryTime = new Date();
|
|
expiryTime.setDate(expiryTime.getDate() + 30);
|
|
|
|
// Update stored tokens minimally to avoid parsing issues
|
|
localStorage.setItem(TOKEN_KEY, data.access_token || data.token);
|
|
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token || data.refreshToken || refreshToken);
|
|
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
|
|
|
|
// Store user data if provided
|
|
if (data.user) {
|
|
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
|
|
}
|
|
|
|
// Reset counters on success
|
|
localStorage.setItem('refresh_attempts', '0');
|
|
sessionStorage.removeItem('redirect_count');
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Token refresh error:', error);
|
|
// Clear stored auth data on refresh failure
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
localStorage.removeItem(USER_DATA_KEY);
|
|
localStorage.removeItem('refresh_attempts');
|
|
sessionStorage.removeItem('redirect_count');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if this is the first run (no admin exists)
|
|
*/
|
|
async function checkFirstRun() {
|
|
try {
|
|
console.log("Checking if this is first run");
|
|
|
|
// Skip the actual check - we'll assume it's not the first run
|
|
// This avoids making the test request that's getting 403 Forbidden
|
|
|
|
// For development/testing we can return false to show login screen
|
|
// or true to show admin setup screen
|
|
return false;
|
|
} catch (error) {
|
|
console.error('Error checking first run:', error);
|
|
// If there's an error, default to regular login
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redirect to main application
|
|
*/
|
|
function redirectToMainApp() {
|
|
console.log('Redirecting to main application');
|
|
|
|
try {
|
|
// Reset refresh attempts counter on redirection
|
|
localStorage.setItem('refresh_attempts', '0');
|
|
sessionStorage.removeItem('redirect_count');
|
|
|
|
// Set a token expiry if none exists
|
|
const tokenExpiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
|
|
if (!tokenExpiry) {
|
|
const expiryTime = new Date();
|
|
expiryTime.setDate(expiryTime.getDate() + 30);
|
|
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
|
|
}
|
|
|
|
// Verify we have a valid token before redirecting
|
|
const hasToken = localStorage.getItem(TOKEN_KEY);
|
|
if (!hasToken) {
|
|
console.error('No token found, cannot redirect to app');
|
|
return;
|
|
}
|
|
|
|
// Navigate to the main app
|
|
window.location.replace('/');
|
|
} catch (error) {
|
|
console.error('Error during redirect:', error);
|
|
window.location.href = '/login?error=redirect_failed';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logout - clear tokens and redirect to login
|
|
*/
|
|
function logout() {
|
|
// Clear user-specific recent files and favorites before removing user data
|
|
try {
|
|
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
|
|
if (userData.username) {
|
|
localStorage.removeItem(`oxicloud_recent_files_${userData.username}`);
|
|
localStorage.removeItem(`oxicloud_favorites_${userData.username}`);
|
|
}
|
|
} catch (e) {
|
|
// Ignore parse errors during cleanup
|
|
}
|
|
// Also remove any legacy global keys
|
|
localStorage.removeItem('oxicloud_recent_files');
|
|
localStorage.removeItem('oxicloud_favorites');
|
|
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
localStorage.removeItem(USER_DATA_KEY);
|
|
window.location.href = '/login';
|
|
} |