Skip to content

Commit e92cd8b

Browse files
committed
Implements OAuth-based user auth with PKCE
Ensures secure user login and logout via authorization code flow Adds callback handling and environment configuration for PKCE Validates tokens before GraphQL requests to improve user experience
1 parent f573bce commit e92cd8b

File tree

10 files changed

+564
-9
lines changed

10 files changed

+564
-9
lines changed

editor/src/App.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,49 @@ export default function App() {
5757
const { script } = state;
5858
const { isValid, errors: validationErrors } = state.validation;
5959

60+
// Handle OAuth callback - monitor URL changes
61+
useEffect(() => {
62+
const handleOAuthCallback = async () => {
63+
const currentUrl = window.location.href;
64+
const urlPath = window.location.pathname;
65+
66+
// Check if we're on the callback route with an authorization code
67+
console.log('🔍 Checking OAuth callback:', { urlPath, hasCode: currentUrl.includes('code=') });
68+
69+
if (urlPath === '/account/callback' && currentUrl.includes('code=')) {
70+
console.log('🔄 OAuth callback detected:', currentUrl);
71+
try {
72+
const { authService } = await import('./lib/auth-service');
73+
await authService.handleOAuthCallback(currentUrl);
74+
75+
// Clear the URL parameters and redirect to main app
76+
window.history.replaceState({}, document.title, '/');
77+
78+
console.log('✅ OAuth login successful, reloading app...');
79+
window.location.reload();
80+
} catch (error) {
81+
console.error('❌ OAuth callback failed:', error);
82+
alert('Login failed: ' + (error as Error).message);
83+
window.history.replaceState({}, document.title, '/');
84+
}
85+
}
86+
};
87+
88+
// Handle callback on mount
89+
handleOAuthCallback();
90+
91+
// Also listen for URL changes (in case of navigation without page reload)
92+
const handlePopState = () => {
93+
handleOAuthCallback();
94+
};
95+
96+
window.addEventListener('popstate', handlePopState);
97+
98+
return () => {
99+
window.removeEventListener('popstate', handlePopState);
100+
};
101+
}, []);
102+
60103
// One-time migration effect: ensure any pre-existing steps have required setup & at least one condition
61104
const [migrationComplete, setMigrationComplete] = useState(false);
62105
useEffect(() => {

editor/src/components/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
117117
setTimeout(() => setExecMessage(null), 6000);
118118
}
119119
}
120+
120121
return (
121122
<div className="tree-sidebar">
122123
<div className="sidebar-content">

editor/src/components/TopBar.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState } from 'react';
22
import { FacilitySelectorPortal } from './FacilitySelectorPortal';
33
import { BuildVersion } from './BuildVersion';
4+
import { useAuth } from '../lib/AuthProvider';
45

56
interface Facility {
67
id: string;
@@ -21,6 +22,8 @@ export const TopBar: React.FC<TopBarProps> = ({
2122
selectedFacilityId,
2223
onFacilitySelect
2324
}) => {
25+
const { isAuthenticated, isLoading, loginWithOAuth, logout } = useAuth();
26+
2427
const handleFacilitySelect = (facility: Facility | null) => {
2528
onFacilitySelect(facility);
2629
console.log('Selected facility:', facility);
@@ -49,6 +52,19 @@ export const TopBar: React.FC<TopBarProps> = ({
4952
</div>
5053
</div>
5154
<div className="top-bar-right">
55+
<div className="top-bar-auth">
56+
{isLoading ? (
57+
<span className="auth-loading">🔄</span>
58+
) : isAuthenticated ? (
59+
<button onClick={logout} className="auth-button logout">
60+
🔓 Logout
61+
</button>
62+
) : (
63+
<button onClick={loginWithOAuth} className="auth-button login">
64+
🔑 Login
65+
</button>
66+
)}
67+
</div>
5268
<BuildVersion />
5369
</div>
5470
</div>

editor/src/lib/AuthProvider.tsx

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ interface AuthContextType {
66
isLoading: boolean;
77
error: string | null;
88
login: () => Promise<void>;
9-
logout: () => void;
9+
loginWithOAuth: () => Promise<void>;
10+
logout: () => Promise<void>;
1011
refreshAuth: () => Promise<void>;
1112
tokenInfo: { isValid: boolean; expiresAt?: Date; scope?: string };
1213
}
@@ -28,25 +29,32 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
2829
}, []);
2930

3031
const checkAuthStatus = () => {
32+
// Only check for OAuth tokens (user authentication), not client credential tokens
3133
const isAuth = authService.isAuthenticated();
34+
console.log('🔍 Checking auth status:', { isAuth, hasStoredToken: !!localStorage.getItem('trackman_auth_token') });
35+
3236
setIsAuthenticated(isAuth);
3337
setIsLoading(false);
3438

3539
if (!isAuth) {
36-
setError('Not authenticated');
40+
console.log('❌ Not authenticated - user needs to log in');
41+
setError(null); // Don't show error for unauthenticated state
3742
} else {
43+
console.log('✅ User is authenticated');
3844
setError(null);
3945
}
4046
};
4147

4248
const login = async () => {
49+
// This method is for client credential authentication (API-only access)
50+
// For user authentication, use loginWithOAuth() instead
4351
setIsLoading(true);
4452
setError(null);
4553

4654
try {
4755
await authService.getAccessToken();
4856
setIsAuthenticated(true);
49-
console.log('✅ Successfully authenticated');
57+
console.log('✅ Successfully authenticated with client credentials');
5058
} catch (err) {
5159
const errorMessage = err instanceof Error ? err.message : 'Authentication failed';
5260
setError(errorMessage);
@@ -57,11 +65,40 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
5765
}
5866
};
5967

60-
const logout = () => {
61-
authService.clearToken();
62-
setIsAuthenticated(false);
68+
const loginWithOAuth = async () => {
69+
setIsLoading(true);
6370
setError(null);
64-
console.log('🔓 Logged out');
71+
72+
try {
73+
await authService.startOAuthLogin();
74+
// Note: This will redirect away from the app, so we won't reach the lines below
75+
// The OAuth callback will handle setting authentication state
76+
} catch (err) {
77+
const errorMessage = err instanceof Error ? err.message : 'OAuth login failed';
78+
setError(errorMessage);
79+
setIsLoading(false);
80+
console.error('❌ OAuth login failed:', errorMessage);
81+
}
82+
};
83+
84+
const logout = async () => {
85+
setIsLoading(true);
86+
try {
87+
await authService.logoutOAuth();
88+
// Update local state immediately since we're not redirecting
89+
setIsAuthenticated(false);
90+
setError(null);
91+
console.log('🔓 Logged out successfully');
92+
} catch (err) {
93+
// Fallback to local logout if server logout fails
94+
console.warn('⚠️ Server logout failed, falling back to local logout:', err);
95+
authService.clearToken();
96+
setIsAuthenticated(false);
97+
setError(null);
98+
console.log('🔓 Logged out locally');
99+
} finally {
100+
setIsLoading(false);
101+
}
65102
};
66103

67104
const refreshAuth = async () => {
@@ -90,6 +127,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
90127
isLoading,
91128
error,
92129
login,
130+
loginWithOAuth,
93131
logout,
94132
refreshAuth,
95133
tokenInfo,

0 commit comments

Comments
 (0)