Skip to content

Commit 42fbdc8

Browse files
Merge pull request #286 from community-scripts/feat/252_sessions
feat: Add persistent session authentication with configurable duration
2 parents 73776ec + 8e2286d commit 42fbdc8

File tree

8 files changed

+456
-57
lines changed

8 files changed

+456
-57
lines changed

src/app/_components/AuthProvider.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
'use client';
22

3-
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
3+
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
44

55
interface AuthContextType {
66
isAuthenticated: boolean;
77
username: string | null;
88
isLoading: boolean;
9+
expirationTime: number | null;
910
login: (username: string, password: string) => Promise<boolean>;
1011
logout: () => void;
1112
checkAuth: () => Promise<void>;
@@ -21,8 +22,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
2122
const [isAuthenticated, setIsAuthenticated] = useState(false);
2223
const [username, setUsername] = useState<string | null>(null);
2324
const [isLoading, setIsLoading] = useState(true);
25+
const [expirationTime, setExpirationTime] = useState<number | null>(null);
2426

25-
const checkAuth = async () => {
27+
const checkAuthInternal = async (retryCount = 0) => {
2628
try {
2729
// First check if setup is completed
2830
const setupResponse = await fetch('/api/settings/auth-credentials');
@@ -33,30 +35,60 @@ export function AuthProvider({ children }: AuthProviderProps) {
3335
if (!setupData.setupCompleted || !setupData.enabled) {
3436
setIsAuthenticated(false);
3537
setUsername(null);
38+
setExpirationTime(null);
3639
setIsLoading(false);
3740
return;
3841
}
3942
}
4043

4144
// Only verify authentication if setup is completed and auth is enabled
42-
const response = await fetch('/api/auth/verify');
45+
const response = await fetch('/api/auth/verify', {
46+
credentials: 'include', // Ensure cookies are sent
47+
});
4348
if (response.ok) {
44-
const data = await response.json() as { username: string };
49+
const data = await response.json() as {
50+
username: string;
51+
expirationTime?: number | null;
52+
timeUntilExpiration?: number | null;
53+
};
4554
setIsAuthenticated(true);
4655
setUsername(data.username);
56+
setExpirationTime(data.expirationTime ?? null);
4757
} else {
4858
setIsAuthenticated(false);
4959
setUsername(null);
60+
setExpirationTime(null);
61+
62+
// Retry logic for failed auth checks (max 2 retries)
63+
if (retryCount < 2) {
64+
setTimeout(() => {
65+
void checkAuthInternal(retryCount + 1);
66+
}, 500);
67+
return;
68+
}
5069
}
5170
} catch (error) {
5271
console.error('Error checking auth:', error);
5372
setIsAuthenticated(false);
5473
setUsername(null);
74+
setExpirationTime(null);
75+
76+
// Retry logic for network errors (max 2 retries)
77+
if (retryCount < 2) {
78+
setTimeout(() => {
79+
void checkAuthInternal(retryCount + 1);
80+
}, 500);
81+
return;
82+
}
5583
} finally {
5684
setIsLoading(false);
5785
}
5886
};
5987

88+
const checkAuth = useCallback(() => {
89+
return checkAuthInternal(0);
90+
}, []);
91+
6092
const login = async (username: string, password: string): Promise<boolean> => {
6193
try {
6294
const response = await fetch('/api/auth/login', {
@@ -65,12 +97,16 @@ export function AuthProvider({ children }: AuthProviderProps) {
6597
'Content-Type': 'application/json',
6698
},
6799
body: JSON.stringify({ username, password }),
100+
credentials: 'include', // Ensure cookies are received
68101
});
69102

70103
if (response.ok) {
71104
const data = await response.json() as { username: string };
72105
setIsAuthenticated(true);
73106
setUsername(data.username);
107+
108+
// Check auth again to get expiration time
109+
await checkAuth();
74110
return true;
75111
} else {
76112
const errorData = await response.json();
@@ -88,18 +124,20 @@ export function AuthProvider({ children }: AuthProviderProps) {
88124
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
89125
setIsAuthenticated(false);
90126
setUsername(null);
127+
setExpirationTime(null);
91128
};
92129

93130
useEffect(() => {
94131
void checkAuth();
95-
}, []);
132+
}, [checkAuth]);
96133

97134
return (
98135
<AuthContext.Provider
99136
value={{
100137
isAuthenticated,
101138
username,
102139
isLoading,
140+
expirationTime,
103141
login,
104142
logout,
105143
checkAuth,

src/app/_components/GeneralSettingsModal.tsx

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ContextualHelpIcon } from './ContextualHelpIcon';
88
import { useTheme } from './ThemeProvider';
99
import { useRegisterModal } from './modal/ModalStackProvider';
1010
import { api } from '~/trpc/react';
11+
import { useAuth } from './AuthProvider';
1112

1213
interface GeneralSettingsModalProps {
1314
isOpen: boolean;
@@ -17,7 +18,9 @@ interface GeneralSettingsModalProps {
1718
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
1819
useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose });
1920
const { theme, setTheme } = useTheme();
21+
const { isAuthenticated, expirationTime, checkAuth } = useAuth();
2022
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general');
23+
const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>('');
2124
const [githubToken, setGithubToken] = useState('');
2225
const [saveFilter, setSaveFilter] = useState(false);
2326
const [savedFilters, setSavedFilters] = useState<any>(null);
@@ -34,6 +37,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
3437
const [authHasCredentials, setAuthHasCredentials] = useState(false);
3538
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
3639
const [authLoading, setAuthLoading] = useState(false);
40+
const [sessionDurationDays, setSessionDurationDays] = useState(7);
3741

3842
// Auto-sync state
3943
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
@@ -214,11 +218,12 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
214218
try {
215219
const response = await fetch('/api/settings/auth-credentials');
216220
if (response.ok) {
217-
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean };
221+
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean; sessionDurationDays?: number };
218222
setAuthUsername(data.username ?? '');
219223
setAuthEnabled(data.enabled ?? false);
220224
setAuthHasCredentials(data.hasCredentials ?? false);
221225
setAuthSetupCompleted(data.setupCompleted ?? false);
226+
setSessionDurationDays(data.sessionDurationDays ?? 7);
222227
}
223228
} catch (error) {
224229
console.error('Error loading auth credentials:', error);
@@ -227,6 +232,64 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
227232
}
228233
};
229234

235+
// Format expiration time display
236+
const formatExpirationTime = (expTime: number | null): string => {
237+
if (!expTime) return 'No active session';
238+
239+
const now = Date.now();
240+
const timeUntilExpiration = expTime - now;
241+
242+
if (timeUntilExpiration <= 0) {
243+
return 'Session expired';
244+
}
245+
246+
const days = Math.floor(timeUntilExpiration / (1000 * 60 * 60 * 24));
247+
const hours = Math.floor((timeUntilExpiration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
248+
const minutes = Math.floor((timeUntilExpiration % (1000 * 60 * 60)) / (1000 * 60));
249+
250+
const parts: string[] = [];
251+
if (days > 0) {
252+
parts.push(`${days} ${days === 1 ? 'day' : 'days'}`);
253+
}
254+
if (hours > 0) {
255+
parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
256+
}
257+
if (minutes > 0 && days === 0) {
258+
parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
259+
}
260+
261+
if (parts.length === 0) {
262+
return 'Less than a minute';
263+
}
264+
265+
return parts.join(', ');
266+
};
267+
268+
// Update expiration display periodically
269+
useEffect(() => {
270+
const updateExpirationDisplay = () => {
271+
if (expirationTime) {
272+
setSessionExpirationDisplay(formatExpirationTime(expirationTime));
273+
} else {
274+
setSessionExpirationDisplay('');
275+
}
276+
};
277+
278+
updateExpirationDisplay();
279+
280+
// Update every minute
281+
const interval = setInterval(updateExpirationDisplay, 60000);
282+
283+
return () => clearInterval(interval);
284+
}, [expirationTime]);
285+
286+
// Refresh auth when tab changes to auth tab
287+
useEffect(() => {
288+
if (activeTab === 'auth' && isOpen) {
289+
void checkAuth();
290+
}
291+
}, [activeTab, isOpen, checkAuth]);
292+
230293
const saveAuthCredentials = async () => {
231294
if (authPassword !== authConfirmPassword) {
232295
setMessage({ type: 'error', text: 'Passwords do not match' });
@@ -265,6 +328,41 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
265328
}
266329
};
267330

331+
const saveSessionDuration = async (days: number) => {
332+
if (days < 1 || days > 365) {
333+
setMessage({ type: 'error', text: 'Session duration must be between 1 and 365 days' });
334+
return;
335+
}
336+
337+
setAuthLoading(true);
338+
setMessage(null);
339+
340+
try {
341+
const response = await fetch('/api/settings/auth-credentials', {
342+
method: 'PATCH',
343+
headers: {
344+
'Content-Type': 'application/json',
345+
},
346+
body: JSON.stringify({ sessionDurationDays: days }),
347+
});
348+
349+
if (response.ok) {
350+
setMessage({ type: 'success', text: `Session duration updated to ${days} days` });
351+
setSessionDurationDays(days);
352+
setTimeout(() => setMessage(null), 3000);
353+
} else {
354+
const errorData = await response.json();
355+
setMessage({ type: 'error', text: errorData.error ?? 'Failed to update session duration' });
356+
setTimeout(() => setMessage(null), 3000);
357+
}
358+
} catch {
359+
setMessage({ type: 'error', text: 'Failed to update session duration' });
360+
setTimeout(() => setMessage(null), 3000);
361+
} finally {
362+
setAuthLoading(false);
363+
}
364+
};
365+
268366
const toggleAuthEnabled = async (enabled: boolean) => {
269367
setAuthLoading(true);
270368
setMessage(null);
@@ -662,7 +760,10 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
662760
{activeTab === 'auth' && (
663761
<div className="space-y-4 sm:space-y-6">
664762
<div>
665-
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3>
763+
<div className="flex items-center gap-2 mb-3 sm:mb-4">
764+
<h3 className="text-base sm:text-lg font-medium text-foreground">Authentication Settings</h3>
765+
<ContextualHelpIcon section="auth-settings" tooltip="Help with Authentication Settings" />
766+
</div>
666767
<p className="text-sm sm:text-base text-muted-foreground mb-4">
667768
Configure authentication to secure access to your application.
668769
</p>
@@ -699,6 +800,68 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
699800
</div>
700801
</div>
701802

803+
{isAuthenticated && expirationTime && (
804+
<div className="p-4 border border-border rounded-lg">
805+
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
806+
<div className="space-y-2">
807+
<div>
808+
<p className="text-sm text-muted-foreground">Session expires in:</p>
809+
<p className="text-sm font-medium text-foreground">{sessionExpirationDisplay}</p>
810+
</div>
811+
<div>
812+
<p className="text-sm text-muted-foreground">Expiration date:</p>
813+
<p className="text-sm font-medium text-foreground">
814+
{new Date(expirationTime).toLocaleString()}
815+
</p>
816+
</div>
817+
</div>
818+
</div>
819+
)}
820+
821+
<div className="p-4 border border-border rounded-lg">
822+
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
823+
<p className="text-sm text-muted-foreground mb-4">
824+
Configure how long user sessions should last before requiring re-authentication.
825+
</p>
826+
827+
<div className="space-y-3">
828+
<div>
829+
<label htmlFor="session-duration" className="block text-sm font-medium text-foreground mb-1">
830+
Session Duration (days)
831+
</label>
832+
<div className="flex items-center gap-3">
833+
<Input
834+
id="session-duration"
835+
type="number"
836+
min="1"
837+
max="365"
838+
placeholder="Enter days"
839+
value={sessionDurationDays}
840+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
841+
const value = parseInt(e.target.value, 10);
842+
if (!isNaN(value)) {
843+
setSessionDurationDays(value);
844+
}
845+
}}
846+
disabled={authLoading || !authSetupCompleted}
847+
className="w-32"
848+
/>
849+
<span className="text-sm text-muted-foreground">days (1-365)</span>
850+
<Button
851+
onClick={() => saveSessionDuration(sessionDurationDays)}
852+
disabled={authLoading || !authSetupCompleted}
853+
size="sm"
854+
>
855+
Save
856+
</Button>
857+
</div>
858+
<p className="text-xs text-muted-foreground mt-2">
859+
Note: This setting applies to new logins. Current sessions will not be affected.
860+
</p>
861+
</div>
862+
</div>
863+
</div>
864+
702865
<div className="p-4 border border-border rounded-lg">
703866
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
704867
<p className="text-sm text-muted-foreground mb-4">

0 commit comments

Comments
 (0)