Skip to content

Commit

Permalink
Added authentication with JWT tokens, including a few new environment…
Browse files Browse the repository at this point in the history
… variables for configuration
  • Loading branch information
jordan-dalby committed Nov 3, 2024
1 parent 30df304 commit 0939741
Show file tree
Hide file tree
Showing 13 changed files with 609 additions and 47 deletions.
32 changes: 27 additions & 5 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
import React from 'react';
import SnippetStorage from './components/snippets/SnippetStorage';
import { ToastProvider } from './components/toast/Toast';
import { AuthProvider, useAuth } from './context/AuthContext';
import LoginPage from './components/auth/LoginPage';

const AuthenticatedApp: React.FC = () => {
const { isAuthenticated, isAuthRequired, isLoading } = useAuth();

if (isLoading) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</div>
);
}

if (isAuthRequired && !isAuthenticated) {
return <LoginPage />;
}

return <SnippetStorage />;
};

const App: React.FC = () => {
return (
<ToastProvider>
<div className="min-h-screen bg-gray-900">
<SnippetStorage />
</div>
</ToastProvider>
<AuthProvider>
<ToastProvider>
<div className="min-h-screen bg-gray-900">
<AuthenticatedApp />
</div>
</ToastProvider>
</AuthProvider>
);
};

Expand Down
108 changes: 108 additions & 0 deletions client/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
interface AuthConfig {
authRequired: boolean;
}

interface LoginResponse {
token: string;
}

declare global {
interface Window {
BASE_PATH?: string;
}
}

const getBasePath = (): string => {
return window?.BASE_PATH?.endsWith('/') ? window.BASE_PATH.slice(0, -1) : window.BASE_PATH || '';
};

const BASE_PATH = getBasePath();
export const AUTH_API_URL = `${BASE_PATH}/api/auth`;

interface ApiError extends Error {
status?: number;
}

export const AUTH_ERROR_EVENT = 'bytestash:auth_error';
export const authErrorEvent = new CustomEvent(AUTH_ERROR_EVENT);

const handleResponse = async (response: Response) => {
if (response.status === 401 || response.status === 403) {
window.dispatchEvent(authErrorEvent);

const error = new Error('Authentication required') as ApiError;
error.status = response.status;
throw error;
}

if (!response.ok) {
const text = await response.text();
console.error('Error response body:', text);
const error = new Error(`Request failed: ${response.status} ${response.statusText}`) as ApiError;
error.status = response.status;
throw error;
}

return response;
};

const getHeaders = (includeAuth = false) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};

if (includeAuth) {
const token = localStorage.getItem('token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}

return headers;
};

export const getAuthConfig = async (): Promise<AuthConfig> => {
try {
const response = await fetch(`${AUTH_API_URL}/config`);
await handleResponse(response);
return response.json();
} catch (error) {
console.error('Error fetching auth config:', error);
throw error;
}
};

export const verifyToken = async (): Promise<boolean> => {
try {
const response = await fetch(`${AUTH_API_URL}/verify`, {
headers: getHeaders(true)
});

if (response.status === 401 || response.status === 403) {
return false;
}

const data = await response.json();
return data.valid;
} catch (error) {
console.error('Error verifying token:', error);
return false;
}
};

export const login = async (username: string, password: string): Promise<string> => {
try {
const response = await fetch(`${AUTH_API_URL}/login`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ username, password })
});

await handleResponse(response);
const data: LoginResponse = await response.json();
return data.token;
} catch (error) {
console.error('Error logging in:', error);
throw error;
}
};
85 changes: 53 additions & 32 deletions client/src/api/snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,59 @@ const getBasePath = (): string => {
};

const BASE_PATH = getBasePath();

export const API_URL = `${BASE_PATH}/api/snippets`;

interface ApiError extends Error {
status?: number;
}

export const AUTH_ERROR_EVENT = 'bytestash:auth_error';
export const authErrorEvent = new CustomEvent(AUTH_ERROR_EVENT);

const handleResponse = async (response: Response) => {
if (response.status === 401 || response.status === 403) {
window.dispatchEvent(authErrorEvent);

const error = new Error('Authentication required') as ApiError;
error.status = response.status;
throw error;
}

if (!response.ok) {
const text = await response.text();
console.error('Error response body:', text);
const error = new Error(`Request failed: ${response.status} ${response.statusText}`) as ApiError;
error.status = response.status;
throw error;
}

return response;
};

const getHeaders = () => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};

const token = localStorage.getItem('token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}

return headers;
};

export const fetchSnippets = async (): Promise<Snippet[]> => {
try {
const response = await fetch(API_URL);

if (!response.ok) {
const text = await response.text();
console.error('Error response body:', text);
throw new Error(`Failed to fetch snippets: ${response.status} ${response.statusText}`);
}
const response = await fetch(API_URL, {
headers: getHeaders()
});

await handleResponse(response);
const text = await response.text();

try {
const data = JSON.parse(text);
return data;
return JSON.parse(text);
} catch (e) {
console.error('Failed to parse JSON:', e);
console.error('Full response:', text);
Expand All @@ -44,18 +79,12 @@ export const createSnippet = async (snippet: Omit<Snippet, 'id' | 'updated_at'>)
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: getHeaders(),
body: JSON.stringify(snippet),
});

if (!response.ok) {
throw new Error('Failed to create snippet');
}

const createdSnippet = await response.json();
return createdSnippet;
await handleResponse(response);
return response.json();
} catch (error) {
console.error('Error creating snippet:', error);
throw error;
Expand All @@ -66,12 +95,10 @@ export const deleteSnippet = async (id: string): Promise<string> => {
try {
const response = await fetch(`${API_URL}/${id}`, {
method: 'DELETE',
headers: getHeaders(),
});

if (!response.ok) {
throw new Error('Failed to delete snippet');
}

await handleResponse(response);
await response.json();
return id;
} catch (error) {
Expand All @@ -84,18 +111,12 @@ export const editSnippet = async (id: string, snippet: Omit<Snippet, 'id' | 'upd
try {
const response = await fetch(`${API_URL}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
headers: getHeaders(),
body: JSON.stringify(snippet),
});

if (!response.ok) {
throw new Error('Failed to update snippet');
}

const updatedSnippet = await response.json();
return updatedSnippet;
await handleResponse(response);
return response.json();
} catch (error) {
console.error('Error updating snippet:', error);
throw error;
Expand Down
84 changes: 84 additions & 0 deletions client/src/components/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import { login } from '../../api/auth';

const LoginPage: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login: setAuth } = useAuth();

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');

try {
const token = await login(username, password);
setAuth(token);
} catch (err) {
setError('Invalid username or password');
} finally {
setIsLoading(false);
}
};

return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold text-white">
ByteStash
</h2>
<p className="mt-2 text-center text-sm text-gray-400">
Please sign in to continue
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<input
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-700 placeholder-gray-500 text-white bg-gray-800 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
/>
</div>
<div>
<input
type="password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-700 placeholder-gray-500 text-white bg-gray-800 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
</div>

<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
</div>
</div>
);
};

export default LoginPage;
Loading

0 comments on commit 0939741

Please sign in to comment.