feat: auth

This commit is contained in:
2025-11-15 21:43:29 +01:00
parent 3c372878a3
commit 2ea90554ef
9 changed files with 1356 additions and 98 deletions

View File

@@ -1,5 +1,6 @@
const API_BASE = '/api/links';
const LISTS_API_BASE = '/api/lists';
const AUTH_API_BASE = '/api/auth';
// DOM elements
const linkForm = document.getElementById('linkForm');
@@ -29,6 +30,17 @@ const listFilterWrapper = document.getElementById('listFilterWrapper');
const listFilterChips = document.getElementById('listFilterChips');
const clearListFilters = document.getElementById('clearListFilters');
const editListsBtn = document.getElementById('editListsBtn');
const loginToggle = document.getElementById('loginToggle');
const loginModal = document.getElementById('loginModal');
const closeLoginModal = document.getElementById('closeLoginModal');
const loginForm = document.getElementById('loginForm');
const loginUsername = document.getElementById('loginUsername');
const loginPassword = document.getElementById('loginPassword');
const loginSubmitBtn = document.getElementById('loginSubmitBtn');
const loginError = document.getElementById('loginError');
const userInfo = document.getElementById('userInfo');
const usernameDisplay = document.getElementById('usernameDisplay');
const logoutBtn = document.getElementById('logoutBtn');
// State
let allLinks = [];
@@ -38,15 +50,20 @@ let showArchived = false;
let currentLayout = localStorage.getItem('linkdingLayout') || 'masonry'; // Load from localStorage or default
let selectedListFilters = [];
let currentLinkForListSelection = null;
let isAuthenticated = false;
let currentUser = null;
// Initialize app
document.addEventListener('DOMContentLoaded', () => {
loadLinks();
loadLists().then(() => {
// After lists are loaded, check URL for list filter
checkUrlForListFilter();
checkAuthStatus().then(() => {
loadLinks();
loadLists().then(() => {
// After lists are loaded, check URL for list filter
checkUrlForListFilter();
});
});
setupEventListeners();
setupAuthentication();
setupScrollToTop();
setupMobileSearch();
setupMobileAddLink();
@@ -54,10 +71,35 @@ document.addEventListener('DOMContentLoaded', () => {
setupListsManagement();
setupListFiltering();
setupUrlNavigation();
setupLogoClick();
setupMobileButtonBlur();
applyLayout(currentLayout);
registerServiceWorker();
});
// Setup button blur on mobile after click
function setupMobileButtonBlur() {
// Check if device is mobile/touch device
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0);
if (isMobile) {
// Add blur handler to all buttons after click
document.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) {
const button = e.target.tagName === 'BUTTON' ? e.target : e.target.closest('button');
// Use setTimeout to ensure blur happens after any click handlers
setTimeout(() => {
if (button && document.activeElement === button) {
button.blur();
}
}, 100);
}
}, true); // Use capture phase to catch all button clicks
}
}
// Register service worker for PWA
function registerServiceWorker() {
if ('serviceWorker' in navigator) {
@@ -73,11 +115,212 @@ function registerServiceWorker() {
}
}
// Authentication functions
async function checkAuthStatus() {
try {
const response = await fetch(AUTH_API_BASE + '/status', {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to check auth status');
const data = await response.json();
const wasAuthenticated = isAuthenticated;
isAuthenticated = data.authenticated;
currentUser = data.user;
updateUIBasedOnAuth();
// If authentication status changed, reload lists to get the correct set
if (wasAuthenticated !== isAuthenticated) {
await loadLists();
}
} catch (error) {
console.error('Error checking auth status:', error);
isAuthenticated = false;
currentUser = null;
updateUIBasedOnAuth();
}
}
function updateUIBasedOnAuth() {
// Show/hide login button and user info
if (isAuthenticated) {
loginToggle.style.display = 'none';
userInfo.style.display = 'flex';
usernameDisplay.textContent = currentUser?.username || 'User';
} else {
loginToggle.style.display = 'flex';
userInfo.style.display = 'none';
}
// Show/hide modify buttons (lists toggle is visible to all for filtering)
const modifyButtons = document.querySelectorAll('.add-link-toggle-btn, .delete-btn, .archive-btn, .add-to-list-btn, #createListBtn, .edit-lists-btn');
modifyButtons.forEach(btn => {
if (btn) {
btn.style.display = isAuthenticated ? '' : 'none';
}
});
// Lists toggle button is visible to all users for filtering
// But the edit button inside the list filter section should be hidden when not authenticated
const editListsBtn = document.getElementById('editListsBtn');
if (editListsBtn) {
editListsBtn.style.display = isAuthenticated ? '' : 'none';
}
// Hide add link wrapper if not authenticated
if (!isAuthenticated && addLinkWrapper) {
addLinkWrapper.classList.remove('show');
if (addLinkToggle) addLinkToggle.classList.remove('active');
}
}
function setupAuthentication() {
// Login toggle
loginToggle.addEventListener('click', () => {
loginModal.classList.add('show');
loginUsername.focus();
});
// Close login modal
closeLoginModal.addEventListener('click', () => {
loginModal.classList.remove('show');
loginForm.reset();
loginError.style.display = 'none';
});
// Close on backdrop click
loginModal.addEventListener('click', (e) => {
if (e.target === loginModal) {
loginModal.classList.remove('show');
loginForm.reset();
loginError.style.display = 'none';
}
});
// Login form submission
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = loginUsername.value.trim();
const password = loginPassword.value;
if (!username || !password) {
showLoginError('Please enter both username and password');
return;
}
// Disable form
loginSubmitBtn.disabled = true;
const btnText = loginSubmitBtn.querySelector('.btn-text');
const btnLoader = loginSubmitBtn.querySelector('.btn-loader');
if (btnText) btnText.style.display = 'none';
if (btnLoader) btnLoader.style.display = 'flex';
loginError.style.display = 'none';
try {
const response = await fetch(AUTH_API_BASE + '/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
isAuthenticated = true;
currentUser = data.user;
updateUIBasedOnAuth();
loginModal.classList.remove('show');
loginForm.reset();
showMessage('Login successful!', 'success');
// Reload lists to get all lists (not just public ones)
loadLists().then(() => {
// Update filter chips if visible
if (listFilterWrapper.classList.contains('show')) {
updateListFilterChips();
}
});
// Refresh links to show all links (not just public ones)
loadLinks();
} catch (error) {
showLoginError(error.message || 'Login failed. Please check your credentials.');
console.error('Login error:', error);
} finally {
loginSubmitBtn.disabled = false;
if (btnText) btnText.style.display = 'inline';
if (btnLoader) btnLoader.style.display = 'none';
}
});
// Logout
logoutBtn.addEventListener('click', async () => {
try {
const response = await fetch(AUTH_API_BASE + '/logout', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) throw new Error('Logout failed');
isAuthenticated = false;
currentUser = null;
updateUIBasedOnAuth();
showMessage('Logged out successfully', 'success');
// Reload lists to get only public lists
loadLists().then(() => {
// Clear any selected filters that are no longer valid (private lists)
selectedListFilters = selectedListFilters.filter(listId => {
return allLists.some(list => list.id === listId && list.public === true);
});
// Update filter chips if visible
if (listFilterWrapper.classList.contains('show')) {
updateListFilterChips();
}
});
// Reset all filters and refresh links to show only public links
resetAllFilters();
} catch (error) {
showMessage('Logout failed', 'error');
console.error('Logout error:', error);
}
});
}
function showLoginError(message) {
loginError.textContent = message;
loginError.style.display = 'block';
}
// Helper function to handle API errors, especially authentication errors
async function handleApiError(response, defaultMessage) {
if (response.status === 401) {
// Authentication required
isAuthenticated = false;
currentUser = null;
updateUIBasedOnAuth();
showMessage('Please login to perform this action', 'error');
loginModal.classList.add('show');
return true; // Indicates auth error was handled
}
const data = await response.json().catch(() => ({}));
throw new Error(data.error || defaultMessage);
}
// Event listeners
function setupEventListeners() {
linkForm.addEventListener('submit', handleAddLink);
searchInput.addEventListener('input', handleSearch);
archiveToggle.addEventListener('click', handleToggleArchive);
archiveToggle.addEventListener('change', handleToggleArchive);
}
// Setup search toggle (works on both desktop and mobile)
@@ -91,10 +334,20 @@ function setupMobileSearch() {
// Hide search
searchWrapper.classList.remove('show');
searchToggle.classList.remove('active');
// Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing
requestAnimationFrame(() => {
searchToggle.blur();
if (document.activeElement === searchToggle) {
document.activeElement.blur();
}
});
// Hide add link if visible
if (addLinkWrapper && addLinkWrapper.classList.contains('show')) {
addLinkWrapper.classList.remove('show');
if (addLinkToggle) addLinkToggle.classList.remove('active');
if (addLinkToggle) {
addLinkToggle.classList.remove('active');
addLinkToggle.blur();
}
}
// Clear search and reset if needed
if (searchInput.value.trim()) {
@@ -105,7 +358,10 @@ function setupMobileSearch() {
// Hide add link if visible
if (addLinkWrapper && addLinkWrapper.classList.contains('show')) {
addLinkWrapper.classList.remove('show');
if (addLinkToggle) addLinkToggle.classList.remove('active');
if (addLinkToggle) {
addLinkToggle.classList.remove('active');
addLinkToggle.blur();
}
}
// Show search
searchWrapper.classList.add('show');
@@ -129,6 +385,13 @@ function setupMobileAddLink() {
// Hide add link
addLinkWrapper.classList.remove('show');
addLinkToggle.classList.remove('active');
// Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing
requestAnimationFrame(() => {
addLinkToggle.blur();
if (document.activeElement === addLinkToggle) {
document.activeElement.blur();
}
});
// Clear input if needed
if (linkInput.value.trim()) {
linkInput.value = '';
@@ -137,7 +400,10 @@ function setupMobileAddLink() {
// Hide search if visible
if (searchWrapper && searchWrapper.classList.contains('show')) {
searchWrapper.classList.remove('show');
if (searchToggle) searchToggle.classList.remove('active');
if (searchToggle) {
searchToggle.classList.remove('active');
searchToggle.blur();
}
if (searchInput.value.trim()) {
searchInput.value = '';
loadLinks();
@@ -161,8 +427,18 @@ function setupLayoutToggle() {
// Toggle dropdown on button click
layoutToggle.addEventListener('click', (e) => {
e.stopPropagation();
const wasVisible = layoutDropdown.classList.contains('show');
layoutDropdown.classList.toggle('show');
layoutToggle.classList.toggle('active');
// Blur button when closing dropdown to remove focus/active state
if (wasVisible) {
requestAnimationFrame(() => {
layoutToggle.blur();
if (document.activeElement === layoutToggle) {
document.activeElement.blur();
}
});
}
});
// Close dropdown when clicking outside
@@ -170,6 +446,13 @@ function setupLayoutToggle() {
if (!layoutToggle.contains(e.target) && !layoutDropdown.contains(e.target)) {
layoutDropdown.classList.remove('show');
layoutToggle.classList.remove('active');
// Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing
requestAnimationFrame(() => {
layoutToggle.blur();
if (document.activeElement === layoutToggle) {
document.activeElement.blur();
}
});
}
});
@@ -276,15 +559,17 @@ async function handleAddLink(e) {
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ url })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to add link');
const handled = await handleApiError(response, 'Failed to add link');
if (handled) return;
}
const data = await response.json();
// Ensure archived and listIds properties exist
data.archived = data.archived || false;
data.listIds = data.listIds || [];
@@ -297,6 +582,7 @@ async function handleAddLink(e) {
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ listIds: selectedListFilters })
});
@@ -369,8 +655,7 @@ function handleSearch(e) {
// Handle archive toggle
function handleToggleArchive() {
showArchived = !showArchived;
archiveToggle.classList.toggle('active', showArchived);
showArchived = archiveToggle.checked;
// Re-filter and display links
const query = searchInput.value.trim();
@@ -424,6 +709,9 @@ function displayLinks(links) {
handleAddToLists(linkId);
});
});
// Update UI based on auth status after rendering
updateUIBasedOnAuth();
}
// Create link card HTML
@@ -566,10 +854,14 @@ async function handleArchiveLink(id) {
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ archived: newArchivedStatus })
});
if (!response.ok) throw new Error('Failed to archive link');
if (!response.ok) {
const handled = await handleApiError(response, 'Failed to archive link');
if (handled) return;
}
// Update local array
link.archived = newArchivedStatus;
@@ -598,10 +890,14 @@ async function handleDeleteLink(id) {
try {
const response = await fetch(`${API_BASE}/${id}`, {
method: 'DELETE'
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to delete link');
if (!response.ok) {
const handled = await handleApiError(response, 'Failed to delete link');
if (handled) return;
}
// Remove from local array
allLinks = allLinks.filter(link => link.id !== id);
@@ -665,11 +961,27 @@ async function loadLists() {
// Setup lists management
function setupListsManagement() {
// Toggle filter section visibility
listsToggle.addEventListener('click', () => {
listsToggle.addEventListener('click', (e) => {
const isVisible = listFilterWrapper.classList.contains('show');
if (isVisible) {
listFilterWrapper.classList.remove('show');
listsToggle.classList.remove('active');
// Force remove active state on mobile - use double requestAnimationFrame for listsToggle
requestAnimationFrame(() => {
listsToggle.blur();
if (document.activeElement === listsToggle) {
document.activeElement.blur();
}
// Second frame to ensure it's fully cleared
requestAnimationFrame(() => {
listsToggle.blur();
// Force style recalculation
listsToggle.style.pointerEvents = 'none';
setTimeout(() => {
listsToggle.style.pointerEvents = '';
}, 0);
});
});
// Reset filters when hiding the section
selectedListFilters = [];
updateUrlForListFilter();
@@ -817,15 +1129,17 @@ async function handleCreateList() {
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ name })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to create list');
const handled = await handleApiError(response, 'Failed to create list');
if (handled) return;
}
const data = await response.json();
allLists.push(data);
newListName.value = '';
renderListsList();
@@ -856,15 +1170,17 @@ async function handleUpdateList(listId) {
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ name })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to update list');
const handled = await handleApiError(response, 'Failed to update list');
if (handled) return;
}
const data = await response.json();
const listIndex = allLists.findIndex(l => l.id === listId);
if (listIndex !== -1) {
allLists[listIndex] = data;
@@ -899,15 +1215,17 @@ async function handleTogglePublic(listId) {
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ public: newPublicStatus })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to update list public status');
const handled = await handleApiError(response, 'Failed to update list public status');
if (handled) return;
}
const data = await response.json();
// Update local array
const listIndex = allLists.findIndex(l => l.id === listId);
if (listIndex !== -1) {
@@ -930,10 +1248,14 @@ async function handleDeleteList(listId) {
try {
const response = await fetch(`${LISTS_API_BASE}/${listId}`, {
method: 'DELETE'
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to delete list');
if (!response.ok) {
const handled = await handleApiError(response, 'Failed to delete list');
if (handled) return;
}
allLists = allLists.filter(list => list.id !== listId);
selectedListFilters = selectedListFilters.filter(id => id !== listId);
@@ -992,15 +1314,17 @@ async function handleCheckboxChange(linkId) {
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ listIds: selectedListIds })
});
const data = await response.json();
if (!response.ok) {
throw new Error('Failed to update link lists');
const handled = await handleApiError(response, 'Failed to update link lists');
if (handled) return;
}
const data = await response.json();
// Update local array
const linkIndex = allLinks.findIndex(l => l.id === linkId);
if (linkIndex !== -1) {
@@ -1022,6 +1346,41 @@ async function handleCheckboxChange(linkId) {
}
}
// Reset all filters and load all links
function resetAllFilters() {
// Clear search
searchInput.value = '';
// Reset archive toggle
showArchived = false;
archiveToggle.checked = false;
// Clear list filters
selectedListFilters = [];
updateUrlForListFilter();
if (listFilterWrapper.classList.contains('show')) {
updateListFilterChips();
}
// Hide search and add link wrappers if visible
searchWrapper.classList.remove('show');
searchToggle.classList.remove('active');
addLinkWrapper.classList.remove('show');
addLinkToggle.classList.remove('active');
// Load all links
loadLinks();
}
// Setup logo click to reset filters
function setupLogoClick() {
const appLogo = document.getElementById('appLogo');
if (appLogo) {
appLogo.style.cursor = 'pointer';
appLogo.addEventListener('click', resetAllFilters);
}
}
// Setup list filtering
function setupListFiltering() {
clearListFilters.addEventListener('click', () => {
@@ -1081,7 +1440,14 @@ function updateUrlForListFilter() {
// Update list filter chips
function updateListFilterChips() {
if (allLists.length === 0) {
// Filter lists based on authentication status
let listsToShow = allLists;
if (!isAuthenticated) {
// Only show public lists when not logged in
listsToShow = allLists.filter(list => list.public === true);
}
if (listsToShow.length === 0) {
// Don't hide the wrapper if it's already shown, just show empty state
listFilterChips.innerHTML = '<p class="empty-lists-message" style="padding: 0.5rem 0; color: var(--text-muted); font-size: 0.875rem;">No lists available. Click Edit to create lists.</p>';
return;
@@ -1092,7 +1458,7 @@ function updateListFilterChips() {
return;
}
listFilterChips.innerHTML = allLists.map(list => {
listFilterChips.innerHTML = listsToShow.map(list => {
const isSelected = selectedListFilters.includes(list.id);
return `
<button class="list-filter-chip ${isSelected ? 'active' : ''}" data-id="${list.id}">