Skip to content

Commit f8e9c67

Browse files
committed
feat: add auth pages, API client, and auth context
1 parent a553ba1 commit f8e9c67

11 files changed

Lines changed: 679 additions & 25 deletions

File tree

src/App.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Routes, Route, Navigate } from 'react-router';
2-
import { useState, useCallback } from 'react';
2+
import { AuthContext, useAuthProvider, useAuth } from './hooks/useAuth';
33
import PageLayout from './components/layout/PageLayout';
44
import ProtectedRoute from './components/layout/ProtectedRoute';
55
import Overview from './pages/Overview';
@@ -16,20 +16,20 @@ import Register from './pages/Register';
1616
import ForgotPassword from './pages/ForgotPassword';
1717
import ResetPassword from './pages/ResetPassword';
1818

19-
export default function App() {
20-
const [isAuthenticated, setIsAuthenticated] = useState(false);
21-
22-
const handleLogout = useCallback(() => {
23-
setIsAuthenticated(false);
24-
}, []);
19+
function AppRoutes() {
20+
const { isAuthenticated, isLoading, logout } = useAuth();
2521

26-
const handleLogin = useCallback(() => {
27-
setIsAuthenticated(true);
28-
}, []);
22+
if (isLoading) {
23+
return (
24+
<div className="flex min-h-screen items-center justify-center bg-surface">
25+
<p className="text-outline">Loading...</p>
26+
</div>
27+
);
28+
}
2929

3030
return (
3131
<Routes>
32-
<Route path="/login" element={<Login onLogin={handleLogin} />} />
32+
<Route path="/login" element={<Login />} />
3333
<Route path="/register" element={<Register />} />
3434
<Route path="/forgot-password" element={<ForgotPassword />} />
3535
<Route path="/reset-password" element={<ResetPassword />} />
@@ -38,7 +38,7 @@ export default function App() {
3838
path="/*"
3939
element={
4040
<ProtectedRoute isAuthenticated={isAuthenticated}>
41-
<PageLayout onLogout={handleLogout}>
41+
<PageLayout onLogout={logout}>
4242
<Routes>
4343
<Route path="/" element={<Overview />} />
4444
<Route path="/agents" element={<Agents />} />
@@ -58,3 +58,13 @@ export default function App() {
5858
</Routes>
5959
);
6060
}
61+
62+
export default function App() {
63+
const auth = useAuthProvider();
64+
65+
return (
66+
<AuthContext.Provider value={auth}>
67+
<AppRoutes />
68+
</AuthContext.Provider>
69+
);
70+
}

src/api/auth.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { apiClient } from './client';
2+
3+
export interface Developer {
4+
id: string;
5+
email: string;
6+
name: string;
7+
avatarUrl?: string;
8+
createdAt: string;
9+
}
10+
11+
interface AuthResponse {
12+
accessToken: string;
13+
developer: Developer;
14+
}
15+
16+
export function login(email: string, password: string) {
17+
return apiClient<AuthResponse>('/auth/login', {
18+
method: 'POST',
19+
body: JSON.stringify({ email, password }),
20+
});
21+
}
22+
23+
export function register(name: string, email: string, password: string) {
24+
return apiClient<AuthResponse>('/auth/register', {
25+
method: 'POST',
26+
body: JSON.stringify({ name, email, password }),
27+
});
28+
}
29+
30+
export function logout() {
31+
return apiClient<void>('/auth/logout', { method: 'POST' });
32+
}
33+
34+
export function getMe() {
35+
return apiClient<Developer>('/auth/me');
36+
}
37+
38+
export function forgotPassword(email: string) {
39+
return apiClient<{ message: string }>('/auth/forgot-password', {
40+
method: 'POST',
41+
body: JSON.stringify({ email }),
42+
});
43+
}
44+
45+
export function resetPassword(token: string, password: string) {
46+
return apiClient<{ message: string }>('/auth/reset-password', {
47+
method: 'POST',
48+
body: JSON.stringify({ token, password }),
49+
});
50+
}
51+
52+
export function getGoogleOAuthUrl() {
53+
return `${import.meta.env.VITE_GATEWAY_URL || 'https://api.wraithprotocol.xyz'}/auth/google`;
54+
}
55+
56+
export function getGithubOAuthUrl() {
57+
return `${import.meta.env.VITE_GATEWAY_URL || 'https://api.wraithprotocol.xyz'}/auth/github`;
58+
}

src/api/client.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { API_URL } from '../config';
2+
3+
let accessToken: string | null = null;
4+
5+
export function getAccessToken(): string | null {
6+
return accessToken;
7+
}
8+
9+
export function setAccessToken(token: string | null) {
10+
accessToken = token;
11+
}
12+
13+
export class AuthError extends Error {
14+
constructor(message: string) {
15+
super(message);
16+
this.name = 'AuthError';
17+
}
18+
}
19+
20+
export class ApiError extends Error {
21+
status: number;
22+
data: unknown;
23+
24+
constructor(status: number, data: unknown) {
25+
super(
26+
typeof data === 'object' && data !== null && 'message' in data
27+
? String((data as { message: string }).message)
28+
: `Request failed with status ${status}`,
29+
);
30+
this.name = 'ApiError';
31+
this.status = status;
32+
this.data = data;
33+
}
34+
}
35+
36+
async function refreshToken(): Promise<boolean> {
37+
try {
38+
const res = await fetch(`${API_URL}/auth/refresh`, {
39+
method: 'POST',
40+
credentials: 'include',
41+
headers: { 'Content-Type': 'application/json' },
42+
});
43+
if (!res.ok) return false;
44+
const data = await res.json();
45+
setAccessToken(data.accessToken);
46+
return true;
47+
} catch {
48+
return false;
49+
}
50+
}
51+
52+
export async function apiClient<T>(path: string, options?: RequestInit): Promise<T> {
53+
const headers: Record<string, string> = {
54+
'Content-Type': 'application/json',
55+
...((options?.headers as Record<string, string>) || {}),
56+
};
57+
58+
const token = getAccessToken();
59+
if (token) {
60+
headers['Authorization'] = `Bearer ${token}`;
61+
}
62+
63+
const res = await fetch(`${API_URL}${path}`, {
64+
...options,
65+
credentials: 'include',
66+
headers,
67+
});
68+
69+
if (res.status === 401) {
70+
const refreshed = await refreshToken();
71+
if (refreshed) return apiClient(path, options);
72+
throw new AuthError('Session expired');
73+
}
74+
75+
if (!res.ok) {
76+
const data = await res.json().catch(() => ({}));
77+
throw new ApiError(res.status, data);
78+
}
79+
80+
const text = await res.text();
81+
if (!text) return undefined as T;
82+
return JSON.parse(text) as T;
83+
}

src/components/auth/LoginForm.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useState } from 'react';
2+
import { Link } from 'react-router';
3+
import { useAuth } from '../../hooks/useAuth';
4+
import OAuthButtons from './OAuthButtons';
5+
6+
export default function LoginForm() {
7+
const { login } = useAuth();
8+
const [email, setEmail] = useState('');
9+
const [password, setPassword] = useState('');
10+
const [error, setError] = useState('');
11+
const [loading, setLoading] = useState(false);
12+
13+
async function handleSubmit(e: React.FormEvent) {
14+
e.preventDefault();
15+
setError('');
16+
setLoading(true);
17+
try {
18+
await login(email, password);
19+
} catch (err) {
20+
setError(err instanceof Error ? err.message : 'Login failed');
21+
} finally {
22+
setLoading(false);
23+
}
24+
}
25+
26+
return (
27+
<div className="w-full max-w-sm border border-outline-variant bg-surface-container p-8">
28+
<h1 className="mb-6 font-heading text-2xl font-semibold text-on-surface">Login</h1>
29+
30+
<OAuthButtons />
31+
32+
<div className="my-6 flex items-center gap-3">
33+
<div className="h-px flex-1 bg-outline-variant" />
34+
<span className="text-xs text-outline">or</span>
35+
<div className="h-px flex-1 bg-outline-variant" />
36+
</div>
37+
38+
<form onSubmit={handleSubmit} className="space-y-4">
39+
{error && (
40+
<div className="border border-error bg-error/10 px-3 py-2 text-sm text-error">
41+
{error}
42+
</div>
43+
)}
44+
45+
<div>
46+
<label htmlFor="email" className="mb-1 block text-sm text-on-surface-variant">
47+
Email
48+
</label>
49+
<input
50+
id="email"
51+
type="email"
52+
required
53+
value={email}
54+
onChange={(e) => setEmail(e.target.value)}
55+
className="w-full border border-outline-variant bg-surface-bright px-3 py-2 text-sm text-on-surface outline-none focus:border-primary"
56+
/>
57+
</div>
58+
59+
<div>
60+
<label htmlFor="password" className="mb-1 block text-sm text-on-surface-variant">
61+
Password
62+
</label>
63+
<input
64+
id="password"
65+
type="password"
66+
required
67+
value={password}
68+
onChange={(e) => setPassword(e.target.value)}
69+
className="w-full border border-outline-variant bg-surface-bright px-3 py-2 text-sm text-on-surface outline-none focus:border-primary"
70+
/>
71+
</div>
72+
73+
<button
74+
type="submit"
75+
disabled={loading}
76+
className="w-full bg-primary px-4 py-2 text-sm font-medium text-surface transition-colors hover:bg-primary/90 disabled:opacity-50"
77+
>
78+
{loading ? 'Logging in...' : 'Login'}
79+
</button>
80+
</form>
81+
82+
<div className="mt-4 flex items-center justify-between text-sm">
83+
<Link to="/forgot-password" className="text-outline hover:text-on-surface-variant">
84+
Forgot password?
85+
</Link>
86+
<Link to="/register" className="text-outline hover:text-on-surface-variant">
87+
Create account
88+
</Link>
89+
</div>
90+
</div>
91+
);
92+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { getGoogleOAuthUrl, getGithubOAuthUrl } from '../../api/auth';
2+
3+
export default function OAuthButtons() {
4+
return (
5+
<div className="space-y-3">
6+
<a
7+
href={getGoogleOAuthUrl()}
8+
className="flex w-full items-center justify-center gap-2 border border-outline-variant bg-surface-bright px-4 py-2.5 text-sm text-on-surface-variant transition-colors hover:bg-surface"
9+
>
10+
<GoogleIcon />
11+
Continue with Google
12+
</a>
13+
<a
14+
href={getGithubOAuthUrl()}
15+
className="flex w-full items-center justify-center gap-2 border border-outline-variant bg-surface-bright px-4 py-2.5 text-sm text-on-surface-variant transition-colors hover:bg-surface"
16+
>
17+
<GithubIcon />
18+
Continue with GitHub
19+
</a>
20+
</div>
21+
);
22+
}
23+
24+
function GoogleIcon() {
25+
return (
26+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
27+
<path d="M8 3.3c1.2 0 2.2.4 3 1.2l2.2-2.2C11.8.9 10 0 8 0 4.9 0 2.2 1.8.8 4.4l2.6 2C4 4.5 5.8 3.3 8 3.3z" />
28+
<path d="M15.6 8.2c0-.6-.1-1.2-.2-1.8H8v3.4h4.3c-.2 1-.7 1.8-1.5 2.4l2.5 1.9c1.4-1.3 2.3-3.3 2.3-5.9z" />
29+
<path d="M3.4 9.6c-.2-.6-.3-1-.3-1.6s.1-1.1.3-1.6L.8 4.4C.3 5.5 0 6.7 0 8s.3 2.5.8 3.6l2.6-2z" />
30+
<path d="M8 16c2 0 3.8-.7 5.1-1.8l-2.5-1.9c-.7.5-1.6.7-2.6.7-2.2 0-4-1.5-4.6-3.4l-2.6 2C2.2 14.2 4.9 16 8 16z" />
31+
</svg>
32+
);
33+
}
34+
35+
function GithubIcon() {
36+
return (
37+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
38+
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
39+
</svg>
40+
);
41+
}

0 commit comments

Comments
 (0)