Files
Oxicloud/static/js/auth.js
T

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';
}