Skip to content

Commit 8a63bad

Browse files
authored
Merge pull request #1061 from objectstack-ai/copilot/refactor-auth-client-to-better-auth
2 parents 986c99e + 00ef6d3 commit 8a63bad

File tree

8 files changed

+226
-157
lines changed

8 files changed

+226
-157
lines changed

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1305,7 +1305,7 @@ Plugin architecture refactoring to support true modular development, plugin isol
13051305
**Fix:**
13061306
1. Added `AuthPlugin` from `@objectstack/plugin-auth` to `objectstack.config.ts` for server mode (`pnpm dev:server`).
13071307
2. Created `authHandlers.ts` with in-memory mock implementations of better-auth endpoints for MSW mode (`pnpm dev`). Mock handlers are added to `customHandlers` in both `browser.ts` and `server.ts`.
1308-
3. Mock handlers support: sign-up/email, sign-in/email, get-session, sign-out, forgot-password, reset-password, update-user.
1308+
3. Mock handlers support: sign-up/email, sign-in/email, get-session, sign-out, forget-password (better-auth convention), reset-password, update-user.
13091309

13101310
**Tests:** 11 new auth handler tests, all existing MSW (7) and auth (24) tests pass.
13111311

apps/console/src/__tests__/authHandlers.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ describe('Mock Auth Handlers', () => {
143143
expect(sessionRes.status).toBe(401);
144144
});
145145

146-
it('should handle forgot-password', async () => {
147-
const res = await fetch(`${BASE_URL}/forgot-password`, {
146+
it('should handle forget-password', async () => {
147+
const res = await fetch(`${BASE_URL}/forget-password`, {
148148
method: 'POST',
149149
headers: { 'Content-Type': 'application/json' },
150150
body: JSON.stringify({ email: 'alice@example.com' }),

apps/console/src/mocks/authHandlers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* POST /sign-in/email — authenticate with email + password
1515
* GET /get-session — retrieve the current session
1616
* POST /sign-out — clear the session
17-
* POST /forgot-password — no-op acknowledgement
17+
* POST /forget-password — no-op acknowledgement (better-auth convention)
1818
* POST /reset-password — no-op acknowledgement
1919
* POST /update-user — update the current user's profile
2020
*/
@@ -163,8 +163,9 @@ export function createAuthHandlers(baseUrl: string): HttpHandler[] {
163163
}),
164164

165165
// ── Forgot Password (mock acknowledgement) ──────────────────────────
166-
http.post(`${p}/forgot-password`, () => {
167-
return HttpResponse.json({ success: true });
166+
// better-auth uses "forget-password" (not "forgot-password")
167+
http.post(`${p}/forget-password`, () => {
168+
return HttpResponse.json({ status: true });
168169
}),
169170

170171
// ── Reset Password (mock acknowledgement) ────────────────────────────

packages/auth/README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Authentication system for Object UI — AuthProvider, guards, login/register for
88
- 🛡️ **AuthGuard** - Protect routes and components from unauthenticated access
99
- 📝 **Pre-built Forms** - LoginForm, RegisterForm, and ForgotPasswordForm ready to use
1010
- 👤 **UserMenu** - Display authenticated user info with sign-out support
11-
- 🔑 **Auth Client Factory** - `createAuthClient` for pluggable backend integration
11+
- 🔑 **Auth Client Factory** - `createAuthClient` powered by official [better-auth](https://better-auth.com) client
1212
- 🌐 **Authenticated Fetch** - `createAuthenticatedFetch` for automatic token injection
1313
- 👀 **Preview Mode** - Auto-login with simulated identity for marketplace demos and app showcases
1414
- 🎯 **Type-Safe** - Full TypeScript support with exported types
@@ -30,8 +30,7 @@ import { AuthProvider, useAuth, AuthGuard } from '@object-ui/auth';
3030
import { createAuthClient } from '@object-ui/auth';
3131

3232
const authClient = createAuthClient({
33-
provider: 'custom',
34-
apiUrl: 'https://api.example.com/auth',
33+
baseURL: 'https://api.example.com/auth',
3534
});
3635

3736
function App() {

packages/auth/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
"react": "^18.0.0 || ^19.0.0"
3535
},
3636
"dependencies": {
37-
"@object-ui/types": "workspace:*"
37+
"@object-ui/types": "workspace:*",
38+
"better-auth": "^1.5.4"
3839
},
3940
"devDependencies": {
4041
"@types/react": "19.2.14",
Lines changed: 113 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,47 @@
11
/**
2-
* Tests for createAuthClient
2+
* Tests for createAuthClient (backed by official better-auth client)
33
*/
44

55
import { describe, it, expect, vi, beforeEach } from 'vitest';
66
import { createAuthClient } from '../createAuthClient';
77
import type { AuthClient } from '../types';
88

9-
describe('createAuthClient', () => {
10-
let client: AuthClient;
11-
let mockFetch: ReturnType<typeof vi.fn>;
12-
13-
beforeEach(() => {
14-
mockFetch = vi.fn();
15-
client = createAuthClient({ baseURL: '/api/auth', fetchFn: mockFetch });
9+
/**
10+
* Helper: creates a mock fetch that routes requests based on URL
11+
* and records every call for inspection.
12+
*/
13+
function createMockFetch(handlers: Record<string, { status?: number; body: unknown }>) {
14+
const calls: Array<{ url: string; method: string; body: string | null }> = [];
15+
const mockFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
16+
let url: string;
17+
if (typeof input === 'string') {
18+
url = input;
19+
} else if (input instanceof URL) {
20+
url = input.toString();
21+
} else {
22+
url = input.url;
23+
}
24+
calls.push({ url, method: init?.method ?? 'GET', body: init?.body as string | null });
25+
for (const [pattern, handler] of Object.entries(handlers)) {
26+
if (url.includes(pattern)) {
27+
return new Response(JSON.stringify(handler.body), {
28+
status: handler.status ?? 200,
29+
headers: { 'Content-Type': 'application/json' },
30+
});
31+
}
32+
}
33+
return new Response(JSON.stringify({ message: 'Not found' }), {
34+
status: 404,
35+
headers: { 'Content-Type': 'application/json' },
36+
});
1637
});
38+
return { mockFn, calls };
39+
}
1740

41+
describe('createAuthClient', () => {
1842
it('creates a client with all expected methods', () => {
43+
const { mockFn } = createMockFetch({});
44+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
1945
expect(client).toHaveProperty('signIn');
2046
expect(client).toHaveProperty('signUp');
2147
expect(client).toHaveProperty('signOut');
@@ -26,156 +52,150 @@ describe('createAuthClient', () => {
2652
});
2753

2854
it('signIn sends POST to /sign-in/email', async () => {
29-
const mockResponse = {
30-
user: { id: '1', name: 'Test', email: 'test@test.com' },
31-
session: { token: 'tok123' },
32-
};
33-
mockFetch.mockResolvedValueOnce({
34-
ok: true,
35-
json: () => Promise.resolve(mockResponse),
55+
const { mockFn, calls } = createMockFetch({
56+
'/sign-in/email': {
57+
body: {
58+
user: { id: '1', name: 'Test', email: 'test@test.com' },
59+
session: { token: 'tok123', id: 's1', userId: '1', expiresAt: '2025-01-01' },
60+
},
61+
},
3662
});
63+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
3764

3865
const result = await client.signIn({ email: 'test@test.com', password: 'pass123' });
3966

40-
expect(mockFetch).toHaveBeenCalledWith(
41-
'/api/auth/sign-in/email',
42-
expect.objectContaining({
43-
method: 'POST',
44-
credentials: 'include',
45-
body: JSON.stringify({ email: 'test@test.com', password: 'pass123' }),
46-
}),
47-
);
67+
expect(calls).toHaveLength(1);
68+
expect(calls[0].url).toContain('/api/auth/sign-in/email');
69+
expect(calls[0].method).toBe('POST');
70+
expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'test@test.com', password: 'pass123' });
4871
expect(result.user.email).toBe('test@test.com');
4972
expect(result.session.token).toBe('tok123');
5073
});
5174

5275
it('signUp sends POST to /sign-up/email', async () => {
53-
const mockResponse = {
54-
user: { id: '2', name: 'New User', email: 'new@test.com' },
55-
session: { token: 'tok456' },
56-
};
57-
mockFetch.mockResolvedValueOnce({
58-
ok: true,
59-
json: () => Promise.resolve(mockResponse),
76+
const { mockFn, calls } = createMockFetch({
77+
'/sign-up/email': {
78+
body: {
79+
user: { id: '2', name: 'New User', email: 'new@test.com' },
80+
session: { token: 'tok456', id: 's2', userId: '2', expiresAt: '2025-01-01' },
81+
},
82+
},
6083
});
84+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
6185

6286
const result = await client.signUp({ name: 'New User', email: 'new@test.com', password: 'pass123' });
6387

64-
expect(mockFetch).toHaveBeenCalledWith(
65-
'/api/auth/sign-up/email',
66-
expect.objectContaining({ method: 'POST' }),
67-
);
88+
expect(calls).toHaveLength(1);
89+
expect(calls[0].url).toContain('/api/auth/sign-up/email');
90+
expect(calls[0].method).toBe('POST');
91+
expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'new@test.com', name: 'New User' });
6892
expect(result.user.name).toBe('New User');
6993
});
7094

7195
it('signOut sends POST to /sign-out', async () => {
72-
mockFetch.mockResolvedValueOnce({
73-
ok: true,
74-
json: () => Promise.resolve({}),
96+
const { mockFn, calls } = createMockFetch({
97+
'/sign-out': { body: { success: true } },
7598
});
99+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
76100

77101
await client.signOut();
78102

79-
expect(mockFetch).toHaveBeenCalledWith(
80-
'/api/auth/sign-out',
81-
expect.objectContaining({ method: 'POST' }),
82-
);
103+
expect(calls).toHaveLength(1);
104+
expect(calls[0].url).toContain('/api/auth/sign-out');
105+
expect(calls[0].method).toBe('POST');
83106
});
84107

85108
it('getSession sends GET to /get-session', async () => {
86-
const mockSession = {
87-
user: { id: '1', name: 'Test', email: 'test@test.com' },
88-
session: { token: 'tok789' },
89-
};
90-
mockFetch.mockResolvedValueOnce({
91-
ok: true,
92-
json: () => Promise.resolve(mockSession),
109+
const { mockFn, calls } = createMockFetch({
110+
'/get-session': {
111+
body: {
112+
user: { id: '1', name: 'Test', email: 'test@test.com' },
113+
session: { token: 'tok789', id: 's1', userId: '1', expiresAt: '2025-01-01' },
114+
},
115+
},
93116
});
117+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
94118

95119
const result = await client.getSession();
96120

97-
expect(mockFetch).toHaveBeenCalledWith(
98-
'/api/auth/get-session',
99-
expect.objectContaining({ method: 'GET' }),
100-
);
121+
expect(calls).toHaveLength(1);
122+
expect(calls[0].url).toContain('/api/auth/get-session');
123+
expect(calls[0].method).toBe('GET');
101124
expect(result?.user.id).toBe('1');
102125
});
103126

104127
it('getSession returns null on failure', async () => {
105-
mockFetch.mockRejectedValueOnce(new Error('Network error'));
128+
const { mockFn } = createMockFetch({
129+
'/get-session': { status: 401, body: { message: 'Unauthorized' } },
130+
});
131+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
106132

107133
const result = await client.getSession();
108134
expect(result).toBeNull();
109135
});
110136

111-
it('forgotPassword sends POST to /forgot-password', async () => {
112-
mockFetch.mockResolvedValueOnce({
113-
ok: true,
114-
json: () => Promise.resolve({}),
137+
it('forgotPassword sends POST to /forget-password', async () => {
138+
const { mockFn, calls } = createMockFetch({
139+
'/forget-password': { body: { status: true } },
115140
});
141+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
116142

117143
await client.forgotPassword('test@test.com');
118144

119-
expect(mockFetch).toHaveBeenCalledWith(
120-
'/api/auth/forgot-password',
121-
expect.objectContaining({
122-
method: 'POST',
123-
body: JSON.stringify({ email: 'test@test.com' }),
124-
}),
125-
);
145+
expect(calls).toHaveLength(1);
146+
expect(calls[0].url).toContain('/api/auth/forget-password');
147+
expect(calls[0].method).toBe('POST');
148+
expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'test@test.com' });
126149
});
127150

128151
it('resetPassword sends POST to /reset-password', async () => {
129-
mockFetch.mockResolvedValueOnce({
130-
ok: true,
131-
json: () => Promise.resolve({}),
152+
const { mockFn, calls } = createMockFetch({
153+
'/reset-password': { body: { status: true } },
132154
});
155+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
133156

134157
await client.resetPassword('token123', 'newpass');
135158

136-
expect(mockFetch).toHaveBeenCalledWith(
137-
'/api/auth/reset-password',
138-
expect.objectContaining({
139-
method: 'POST',
140-
body: JSON.stringify({ token: 'token123', newPassword: 'newpass' }),
141-
}),
142-
);
159+
expect(calls).toHaveLength(1);
160+
expect(calls[0].url).toContain('/api/auth/reset-password');
161+
expect(calls[0].method).toBe('POST');
162+
expect(JSON.parse(calls[0].body!)).toMatchObject({ token: 'token123', newPassword: 'newpass' });
143163
});
144164

145165
it('throws error with server message on non-OK response', async () => {
146-
mockFetch.mockResolvedValueOnce({
147-
ok: false,
148-
status: 401,
149-
json: () => Promise.resolve({ message: 'Invalid credentials' }),
166+
const { mockFn } = createMockFetch({
167+
'/sign-in/email': {
168+
status: 401,
169+
body: { message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' },
170+
},
150171
});
172+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
151173

152174
await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow('Invalid credentials');
153175
});
154176

155-
it('throws generic error when response has no message', async () => {
156-
mockFetch.mockResolvedValueOnce({
157-
ok: false,
158-
status: 500,
159-
json: () => Promise.reject(new Error('parse error')),
177+
it('throws error on non-OK response without message', async () => {
178+
const { mockFn } = createMockFetch({
179+
'/sign-in/email': { status: 500, body: {} },
160180
});
181+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
161182

162-
await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow(
163-
'Auth request failed with status 500',
164-
);
183+
await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow();
165184
});
166185

167186
it('updateUser sends POST to /update-user and returns user', async () => {
168-
mockFetch.mockResolvedValueOnce({
169-
ok: true,
170-
json: () => Promise.resolve({ user: { id: '1', name: 'Updated', email: 'test@test.com' } }),
187+
const { mockFn, calls } = createMockFetch({
188+
'/update-user': {
189+
body: { user: { id: '1', name: 'Updated', email: 'test@test.com' } },
190+
},
171191
});
192+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
172193

173194
const result = await client.updateUser({ name: 'Updated' });
174195

196+
expect(calls).toHaveLength(1);
197+
expect(calls[0].url).toContain('/api/auth/update-user');
198+
expect(calls[0].method).toBe('POST');
175199
expect(result.name).toBe('Updated');
176-
expect(mockFetch).toHaveBeenCalledWith(
177-
'/api/auth/update-user',
178-
expect.objectContaining({ method: 'POST' }),
179-
);
180200
});
181201
});

0 commit comments

Comments
 (0)