Skip to content

Commit b5bab25

Browse files
committed
refactor: hook up renderer auth logic to service
1 parent 1c19ed5 commit b5bab25

44 files changed

Lines changed: 926 additions & 387 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/code/src/main/services/auth/service.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,70 @@ describe("AuthService", () => {
171171
"rotated-refresh-token",
172172
);
173173
});
174+
175+
it("preserves the selected project across logout and re-login for the same account", async () => {
176+
vi.mocked(oauthService.startFlow)
177+
.mockResolvedValueOnce({
178+
success: true,
179+
data: {
180+
access_token: "initial-access-token",
181+
refresh_token: "initial-refresh-token",
182+
expires_in: 3600,
183+
token_type: "Bearer",
184+
scope: "",
185+
scoped_teams: [42, 84],
186+
scoped_organizations: ["org-1"],
187+
},
188+
})
189+
.mockResolvedValueOnce({
190+
success: true,
191+
data: {
192+
access_token: "second-access-token",
193+
refresh_token: "second-refresh-token",
194+
expires_in: 3600,
195+
token_type: "Bearer",
196+
scope: "",
197+
scoped_teams: [42, 84],
198+
scoped_organizations: ["org-1"],
199+
},
200+
});
201+
vi.mocked(oauthService.refreshToken).mockResolvedValue({
202+
success: true,
203+
data: {
204+
access_token: "refreshed-access-token",
205+
refresh_token: "refreshed-refresh-token",
206+
expires_in: 3600,
207+
token_type: "Bearer",
208+
scope: "",
209+
scoped_teams: [42, 84],
210+
scoped_organizations: ["org-1"],
211+
},
212+
});
213+
214+
vi.stubGlobal(
215+
"fetch",
216+
vi.fn().mockResolvedValue({
217+
json: vi.fn().mockResolvedValue({ has_access: true }),
218+
}) as unknown as typeof fetch,
219+
);
220+
221+
await service.login("us");
222+
await service.selectProject(84);
223+
await service.logout();
224+
225+
expect(service.getState()).toMatchObject({
226+
status: "anonymous",
227+
cloudRegion: "us",
228+
projectId: 84,
229+
});
230+
231+
await service.login("us");
232+
233+
expect(service.getState()).toMatchObject({
234+
status: "authenticated",
235+
cloudRegion: "us",
236+
projectId: 84,
237+
availableProjectIds: [42, 84],
238+
});
239+
});
174240
});

apps/code/src/main/services/auth/service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,11 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
219219
}
220220

221221
async logout(): Promise<AuthState> {
222+
const { cloudRegion, projectId } = this.state;
223+
222224
this.authSessionRepository.clearCurrent();
223225
this.session = null;
224-
this.setAnonymousState();
226+
this.setAnonymousState({ cloudRegion, projectId });
225227
return this.getState();
226228
}
227229

apps/code/src/renderer/App.tsx

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { ScopeReauthPrompt } from "@components/ScopeReauthPrompt";
55
import { UpdatePrompt } from "@components/UpdatePrompt";
66
import { AuthScreen } from "@features/auth/components/AuthScreen";
77
import { InviteCodeScreen } from "@features/auth/components/InviteCodeScreen";
8-
import { useAuthStore } from "@features/auth/stores/authStore";
8+
import { useAuthStateValue } from "@features/auth/hooks/authQueries";
9+
import { useAuthSession } from "@features/auth/hooks/useAuthSession";
910
import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow";
11+
import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore";
1012
import { Flex, Spinner, Text } from "@radix-ui/themes";
1113
import { initializeConnectivityStore } from "@renderer/stores/connectivityStore";
1214
import { useFocusStore } from "@renderer/stores/focusStore";
@@ -25,10 +27,14 @@ const log = logger.scope("app");
2527

2628
function App() {
2729
const trpcReact = useTRPC();
28-
const { isAuthenticated, hasCompletedOnboarding, hasCodeAccess } =
29-
useAuthStore();
30+
const { isBootstrapped } = useAuthSession();
31+
const authState = useAuthStateValue((state) => state);
32+
const hasCompletedOnboarding = useOnboardingStore(
33+
(state) => state.hasCompletedOnboarding,
34+
);
35+
const isAuthenticated = authState.status === "authenticated";
36+
const hasCodeAccess = authState.hasCodeAccess;
3037
const isDarkMode = useThemeStore((state) => state.isDarkMode);
31-
const [isLoading, setIsLoading] = useState(true);
3238
const [showTransition, setShowTransition] = useState(false);
3339
const wasInMainApp = useRef(isAuthenticated && hasCompletedOnboarding);
3440

@@ -114,15 +120,6 @@ function App() {
114120
}),
115121
);
116122

117-
// Initialize auth state from main process
118-
useEffect(() => {
119-
const initialize = async () => {
120-
await useAuthStore.getState().initializeOAuth();
121-
setIsLoading(false);
122-
};
123-
void initialize();
124-
}, []);
125-
126123
// Handle transition into main app — only show the dark overlay if dark mode is active
127124
useEffect(() => {
128125
const isInMainApp = isAuthenticated && hasCompletedOnboarding;
@@ -136,7 +133,7 @@ function App() {
136133
setShowTransition(false);
137134
};
138135

139-
if (isLoading) {
136+
if (!isBootstrapped) {
140137
return (
141138
<Flex align="center" justify="center" minHeight="100vh">
142139
<Flex align="center" gap="3">
Lines changed: 55 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,40 @@
1+
import { Theme } from "@radix-ui/themes";
12
import { render, screen } from "@testing-library/react";
23
import userEvent from "@testing-library/user-event";
4+
import type { ReactElement } from "react";
35
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
import { ScopeReauthPrompt } from "./ScopeReauthPrompt";
47

5-
vi.mock("@renderer/trpc/client", () => ({
6-
trpcClient: {
7-
auth: {
8-
getState: { query: vi.fn() },
9-
onStateChanged: { subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })) },
10-
getValidAccessToken: {
11-
query: vi.fn().mockResolvedValue({
12-
accessToken: "token",
13-
apiHost: "https://us.posthog.com",
14-
}),
15-
},
16-
refreshAccessToken: {
17-
mutate: vi.fn().mockResolvedValue({
18-
accessToken: "token",
19-
apiHost: "https://us.posthog.com",
20-
}),
21-
},
22-
login: {
23-
mutate: vi.fn().mockResolvedValue({
24-
state: {
25-
status: "authenticated",
26-
bootstrapComplete: true,
27-
cloudRegion: "us",
28-
projectId: 1,
29-
availableProjectIds: [1],
30-
availableOrgIds: [],
31-
hasCodeAccess: true,
32-
needsScopeReauth: false,
33-
},
34-
}),
35-
},
36-
signup: { mutate: vi.fn() },
37-
selectProject: { mutate: vi.fn() },
38-
redeemInviteCode: { mutate: vi.fn() },
39-
logout: {
40-
mutate: vi.fn().mockResolvedValue({
41-
status: "anonymous",
42-
bootstrapComplete: true,
43-
cloudRegion: null,
44-
projectId: null,
45-
availableProjectIds: [],
46-
availableOrgIds: [],
47-
hasCodeAccess: null,
48-
needsScopeReauth: false,
49-
}),
50-
},
51-
},
52-
analytics: {
53-
setUserId: { mutate: vi.fn().mockResolvedValue(undefined) },
54-
resetUser: { mutate: vi.fn().mockResolvedValue(undefined) },
55-
},
56-
},
8+
const authState = {
9+
status: "anonymous" as const,
10+
bootstrapComplete: true,
11+
cloudRegion: null as "us" | "eu" | "dev" | null,
12+
projectId: null,
13+
availableProjectIds: [],
14+
availableOrgIds: [],
15+
hasCodeAccess: null,
16+
needsScopeReauth: false,
17+
};
18+
19+
const mockLoginMutateAsync = vi.fn();
20+
const mockLogoutMutate = vi.fn(() => {
21+
authState.needsScopeReauth = false;
22+
authState.cloudRegion = null;
23+
});
24+
25+
vi.mock("@features/auth/hooks/authQueries", () => ({
26+
useAuthStateValue: (selector: (state: typeof authState) => unknown) =>
27+
selector(authState),
5728
}));
5829

59-
vi.mock("@utils/analytics", () => ({
60-
identifyUser: vi.fn(),
61-
resetUser: vi.fn(),
62-
track: vi.fn(),
30+
vi.mock("@features/auth/hooks/authMutations", () => ({
31+
useLoginMutation: () => ({
32+
mutateAsync: mockLoginMutateAsync,
33+
isPending: false,
34+
}),
35+
useLogoutMutation: () => ({
36+
mutate: mockLogoutMutate,
37+
}),
6338
}));
6439

6540
vi.mock("@utils/logger", () => ({
@@ -73,40 +48,18 @@ vi.mock("@utils/logger", () => ({
7348
},
7449
}));
7550

76-
vi.mock("@utils/queryClient", () => ({
77-
queryClient: {
78-
clear: vi.fn(),
79-
setQueryData: vi.fn(),
80-
removeQueries: vi.fn(),
81-
},
82-
}));
83-
84-
vi.mock("@stores/navigationStore", () => ({
85-
useNavigationStore: {
86-
getState: () => ({ navigateToTaskInput: vi.fn() }),
87-
},
88-
}));
89-
90-
import {
91-
resetAuthStoreModuleStateForTest,
92-
useAuthStore,
93-
} from "@features/auth/stores/authStore";
94-
import { Theme } from "@radix-ui/themes";
95-
import type { ReactElement } from "react";
96-
import { ScopeReauthPrompt } from "./ScopeReauthPrompt";
97-
9851
function renderWithTheme(ui: ReactElement) {
9952
return render(<Theme>{ui}</Theme>);
10053
}
10154

10255
describe("ScopeReauthPrompt", () => {
10356
beforeEach(() => {
104-
localStorage.clear();
105-
resetAuthStoreModuleStateForTest();
106-
useAuthStore.setState({
107-
needsScopeReauth: false,
108-
cloudRegion: null,
109-
});
57+
vi.clearAllMocks();
58+
authState.status = "anonymous";
59+
authState.cloudRegion = null;
60+
authState.projectId = null;
61+
authState.hasCodeAccess = null;
62+
authState.needsScopeReauth = false;
11063
});
11164

11265
it("does not render dialog when needsScopeReauth is false", () => {
@@ -117,25 +70,34 @@ describe("ScopeReauthPrompt", () => {
11770
});
11871

11972
it("renders dialog when needsScopeReauth is true", () => {
120-
useAuthStore.setState({ needsScopeReauth: true, cloudRegion: "us" });
73+
authState.needsScopeReauth = true;
74+
authState.cloudRegion = "us";
75+
12176
renderWithTheme(<ScopeReauthPrompt />);
77+
12278
expect(screen.getByText("Re-authentication required")).toBeInTheDocument();
12379
});
12480

12581
it("disables Sign in button when cloudRegion is null", () => {
126-
useAuthStore.setState({ needsScopeReauth: true, cloudRegion: null });
82+
authState.needsScopeReauth = true;
83+
12784
renderWithTheme(<ScopeReauthPrompt />);
85+
12886
expect(screen.getByRole("button", { name: "Sign in" })).toBeDisabled();
12987
});
13088

13189
it("enables Sign in button when cloudRegion is set", () => {
132-
useAuthStore.setState({ needsScopeReauth: true, cloudRegion: "us" });
90+
authState.needsScopeReauth = true;
91+
authState.cloudRegion = "us";
92+
13393
renderWithTheme(<ScopeReauthPrompt />);
94+
13495
expect(screen.getByRole("button", { name: "Sign in" })).not.toBeDisabled();
13596
});
13697

13798
it("shows Log out button as an escape hatch when cloudRegion is null", () => {
138-
useAuthStore.setState({ needsScopeReauth: true, cloudRegion: null });
99+
authState.needsScopeReauth = true;
100+
139101
renderWithTheme(<ScopeReauthPrompt />);
140102

141103
const logoutButton = screen.getByRole("button", { name: "Log out" });
@@ -145,14 +107,14 @@ describe("ScopeReauthPrompt", () => {
145107

146108
it("calls logout when Log out button is clicked", async () => {
147109
const user = userEvent.setup();
148-
useAuthStore.setState({ needsScopeReauth: true, cloudRegion: null });
110+
authState.needsScopeReauth = true;
111+
149112
renderWithTheme(<ScopeReauthPrompt />);
150113

151114
await user.click(screen.getByRole("button", { name: "Log out" }));
152115

153-
const state = useAuthStore.getState();
154-
expect(state.needsScopeReauth).toBe(false);
155-
expect(state.isAuthenticated).toBe(false);
156-
expect(state.cloudRegion).toBeNull();
116+
expect(mockLogoutMutate).toHaveBeenCalledTimes(1);
117+
expect(authState.needsScopeReauth).toBe(false);
118+
expect(authState.cloudRegion).toBeNull();
157119
});
158120
});

0 commit comments

Comments
 (0)