This commit is contained in:
parent
99151c6692
commit
3676dc46ed
16 changed files with 894 additions and 89 deletions
|
|
@ -1,6 +1,32 @@
|
|||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost/api';
|
||||
|
||||
async function fetchAPI(endpoint, options = {}) {
|
||||
// Track if we're currently refreshing to avoid multiple simultaneous refreshes
|
||||
let isRefreshing = false;
|
||||
let refreshPromise = null;
|
||||
|
||||
// Attempt to refresh the access token
|
||||
async function attemptTokenRefresh() {
|
||||
if (isRefreshing) {
|
||||
// Wait for the existing refresh to complete
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
}).then(response => {
|
||||
isRefreshing = false;
|
||||
return response.ok;
|
||||
}).catch(() => {
|
||||
isRefreshing = false;
|
||||
return false;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
async function fetchAPI(endpoint, options = {}, retryAfterRefresh = true) {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
|
|
@ -10,6 +36,15 @@ async function fetchAPI(endpoint, options = {}) {
|
|||
credentials: 'include', // Always include credentials
|
||||
});
|
||||
|
||||
// If we get a 401 and haven't already retried, attempt token refresh
|
||||
if (response.status === 401 && retryAfterRefresh) {
|
||||
const refreshed = await attemptTokenRefresh();
|
||||
if (refreshed) {
|
||||
// Retry the original request (but don't retry again if it fails)
|
||||
return fetchAPI(endpoint, options, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ class ChatWebSocket {
|
|||
};
|
||||
}
|
||||
|
||||
attemptReconnect() {
|
||||
async attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnect attempts reached. Use manualReconnect() to try again.');
|
||||
connectionStatus.set('failed');
|
||||
|
|
@ -385,7 +385,7 @@ class ChatWebSocket {
|
|||
console.log('Resetting reconnect attempts after timeout');
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.realmId) {
|
||||
this.connect(this.realmId, this.token);
|
||||
this.attemptReconnect();
|
||||
}
|
||||
}, 60000); // Try again after 1 minute
|
||||
return;
|
||||
|
|
@ -400,8 +400,30 @@ class ChatWebSocket {
|
|||
|
||||
console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
if (this.realmId) {
|
||||
// If we have a token, try to refresh it before reconnecting
|
||||
if (this.token) {
|
||||
try {
|
||||
console.log('[ChatWebSocket] Refreshing token before reconnect...');
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Update the token with the fresh one
|
||||
if (data.token) {
|
||||
this.token = data.token;
|
||||
console.log('[ChatWebSocket] Token refreshed successfully');
|
||||
}
|
||||
} else {
|
||||
console.warn('[ChatWebSocket] Token refresh failed, trying with old token');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ChatWebSocket] Token refresh error:', e);
|
||||
}
|
||||
}
|
||||
this.connect(this.realmId, this.token);
|
||||
}
|
||||
}, delay);
|
||||
|
|
@ -410,13 +432,32 @@ class ChatWebSocket {
|
|||
/**
|
||||
* Manually trigger reconnection - resets attempt counter and tries immediately
|
||||
*/
|
||||
manualReconnect() {
|
||||
async manualReconnect() {
|
||||
console.log('Manual reconnect triggered');
|
||||
if (this.reconnectResetTimer) {
|
||||
clearTimeout(this.reconnectResetTimer);
|
||||
this.reconnectResetTimer = null;
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Refresh token before reconnecting if we have one
|
||||
if (this.token && this.realmId) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.token) {
|
||||
this.token = data.token;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Token refresh error during manual reconnect:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.realmId) {
|
||||
this.connect(this.realmId, this.token);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@
|
|||
{message.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<button class="username-btn" style="color: {safeUserColor}" on:click={handleUsernameClick} on:dblclick={handleUsernameDoubleClick}>
|
||||
<button class="username-btn" class:guest={message.isGuest} style="color: {message.isGuest ? 'lightgrey' : safeUserColor}" on:click={handleUsernameClick} on:dblclick={handleUsernameDoubleClick}>
|
||||
{message.username}
|
||||
</button>
|
||||
{#if message.usedRoll}
|
||||
|
|
@ -579,6 +579,12 @@
|
|||
filter: invert(1);
|
||||
}
|
||||
|
||||
.username-btn.guest {
|
||||
background-color: #1f184e;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.55rem;
|
||||
padding: 0.05rem 0.25rem;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { watchSync, canControl, currentVideo } from '$lib/stores/watchSync';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { getGuestFingerprint } from '$lib/fingerprint';
|
||||
// watchSync is already imported above for sendLockUpdate
|
||||
|
||||
export let realmId;
|
||||
export let playlist = [];
|
||||
|
|
@ -295,6 +296,9 @@
|
|||
item.id === itemId ? { ...item, isLocked: locked } : item
|
||||
);
|
||||
dispatch('playlistUpdated');
|
||||
|
||||
// Notify watch sync WebSocket about lock change (for immediate in-memory state update)
|
||||
watchSync.sendLockUpdate(itemId, locked);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle lock:', e);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,71 @@ import { writable, derived } from 'svelte/store';
|
|||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Token refresh interval (2 hours - before the 2.5-hour access token expires)
|
||||
const REFRESH_INTERVAL_MS = 2 * 60 * 60 * 1000;
|
||||
let refreshInterval = null;
|
||||
let isRefreshing = false;
|
||||
|
||||
// Silent token refresh function
|
||||
async function refreshAccessToken() {
|
||||
if (isRefreshing) return false;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Update user data if returned
|
||||
if (data.user) {
|
||||
auth.updateUser(data.user);
|
||||
}
|
||||
isRefreshing = false;
|
||||
return true;
|
||||
} else {
|
||||
// Refresh failed - session expired
|
||||
console.log('Token refresh failed - session expired');
|
||||
isRefreshing = false;
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error);
|
||||
isRefreshing = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start automatic token refresh
|
||||
function startTokenRefresh() {
|
||||
if (!browser) return;
|
||||
|
||||
// Clear any existing interval
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
|
||||
// Refresh every 12 minutes
|
||||
refreshInterval = setInterval(async () => {
|
||||
const success = await refreshAccessToken();
|
||||
if (!success) {
|
||||
// Stop refresh interval and logout
|
||||
stopTokenRefresh();
|
||||
auth.logout();
|
||||
}
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Stop automatic token refresh
|
||||
function stopTokenRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable({
|
||||
user: null,
|
||||
|
|
@ -10,25 +75,29 @@ function createAuthStore() {
|
|||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
|
||||
async init() {
|
||||
if (!browser) return;
|
||||
|
||||
|
||||
// Use cookie-based auth - no localStorage tokens
|
||||
try {
|
||||
const response = await fetch('/api/user/me', {
|
||||
credentials: 'include' // Send cookies
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
set({ user: data.user, loading: false });
|
||||
// Start token refresh when authenticated
|
||||
startTokenRefresh();
|
||||
} else {
|
||||
set({ user: null, loading: false });
|
||||
stopTokenRefresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth init error:', error);
|
||||
set({ user: null, loading: false });
|
||||
stopTokenRefresh();
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -46,6 +115,8 @@ function createAuthStore() {
|
|||
// Server sets httpOnly cookie for HTTP requests
|
||||
// Token is NOT stored in localStorage to prevent XSS attacks
|
||||
set({ user: data.user, loading: false });
|
||||
// Start token refresh after successful login
|
||||
startTokenRefresh();
|
||||
goto('/');
|
||||
return { success: true };
|
||||
}
|
||||
|
|
@ -67,6 +138,8 @@ function createAuthStore() {
|
|||
// Server sets httpOnly cookie for HTTP requests
|
||||
// Token is NOT stored in localStorage to prevent XSS attacks
|
||||
set({ user: data.user, loading: false });
|
||||
// Start token refresh after successful login
|
||||
startTokenRefresh();
|
||||
goto('/');
|
||||
return { success: true };
|
||||
}
|
||||
|
|
@ -132,20 +205,26 @@ function createAuthStore() {
|
|||
},
|
||||
|
||||
async logout() {
|
||||
// Call logout endpoint to clear httpOnly cookie
|
||||
// Stop token refresh interval
|
||||
stopTokenRefresh();
|
||||
|
||||
// Call logout endpoint to clear httpOnly cookies
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// Clear token from localStorage
|
||||
// Clear token from localStorage (legacy cleanup)
|
||||
if (browser) {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
|
||||
set({ user: null, loading: false });
|
||||
goto('/login');
|
||||
}
|
||||
},
|
||||
|
||||
// Export refresh function for use by other modules (e.g., WebSocket)
|
||||
refreshToken: refreshAccessToken
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,15 @@ function createWatchSyncStore() {
|
|||
error: data.error || 'Unknown error'
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'lock_changed':
|
||||
// Notify components about lock state change
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('playlist-lock-changed', {
|
||||
detail: { playlistItemId: data.playlistItemId, locked: data.locked }
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -333,6 +342,15 @@ function createWatchSyncStore() {
|
|||
});
|
||||
}
|
||||
|
||||
// Send lock update to server (for immediate in-memory state update)
|
||||
function sendLockUpdate(playlistItemId, locked) {
|
||||
send({
|
||||
type: 'lock_update',
|
||||
playlistItemId: playlistItemId,
|
||||
locked: locked
|
||||
});
|
||||
}
|
||||
|
||||
// Check if local player needs to sync
|
||||
function checkSync(localTime) {
|
||||
let state;
|
||||
|
|
@ -358,6 +376,7 @@ function createWatchSyncStore() {
|
|||
requestSync,
|
||||
checkSync,
|
||||
reportDuration,
|
||||
sendLockUpdate,
|
||||
getExpectedTime: () => {
|
||||
let state;
|
||||
subscribe(s => { state = s; })();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue