Skip to content

Commit ddd1bec

Browse files
committed
refactor: hook up renderer auth logic to service
1 parent 98a1093 commit ddd1bec

Some content is hidden

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

44 files changed

+913
-386
lines changed

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,58 @@ 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+
202+
vi.stubGlobal(
203+
"fetch",
204+
vi.fn().mockResolvedValue({
205+
json: vi.fn().mockResolvedValue({ has_access: true }),
206+
}) as unknown as typeof fetch,
207+
);
208+
209+
await service.login("us");
210+
await service.selectProject(84);
211+
await service.logout();
212+
213+
expect(service.getState()).toMatchObject({
214+
status: "anonymous",
215+
cloudRegion: "us",
216+
projectId: 84,
217+
});
218+
219+
await service.login("us");
220+
221+
expect(service.getState()).toMatchObject({
222+
status: "authenticated",
223+
cloudRegion: "us",
224+
projectId: 84,
225+
availableProjectIds: [42, 84],
226+
});
227+
});
174228
});

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
});

apps/code/src/renderer/components/ScopeReauthPrompt.tsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,30 @@
1-
import { useAuthStore } from "@features/auth/stores/authStore";
1+
import {
2+
useLoginMutation,
3+
useLogoutMutation,
4+
} from "@features/auth/hooks/authMutations";
5+
import { useAuthStateValue } from "@features/auth/hooks/authQueries";
26
import { ShieldWarning } from "@phosphor-icons/react";
37
import { Button, Dialog, Flex, Text } from "@radix-ui/themes";
48
import { logger } from "@utils/logger";
5-
import { useState } from "react";
69

710
const log = logger.scope("scope-reauth-prompt");
811

912
export function ScopeReauthPrompt() {
10-
const needsScopeReauth = useAuthStore((s) => s.needsScopeReauth);
11-
const cloudRegion = useAuthStore((s) => s.cloudRegion);
12-
const loginWithOAuth = useAuthStore((s) => s.loginWithOAuth);
13-
const logout = useAuthStore((s) => s.logout);
14-
const [isLoading, setIsLoading] = useState(false);
13+
const needsScopeReauth = useAuthStateValue((state) => state.needsScopeReauth);
14+
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
15+
const loginMutation = useLoginMutation();
16+
const logoutMutation = useLogoutMutation();
1517

1618
const handleSignIn = async () => {
1719
if (!cloudRegion) {
1820
log.warn("Cannot re-authenticate: no cloud region available");
1921
return;
2022
}
2123

22-
setIsLoading(true);
2324
try {
24-
await loginWithOAuth(cloudRegion);
25+
await loginMutation.mutateAsync(cloudRegion);
2526
} catch (error) {
2627
log.error("Re-authentication failed", error);
27-
} finally {
28-
setIsLoading(false);
2928
}
3029
};
3130

@@ -50,13 +49,18 @@ export function ScopeReauthPrompt() {
5049
</Text>
5150
</Dialog.Description>
5251
<Flex justify="between" mt="2">
53-
<Button type="button" variant="soft" color="gray" onClick={logout}>
52+
<Button
53+
type="button"
54+
variant="soft"
55+
color="gray"
56+
onClick={() => logoutMutation.mutate()}
57+
>
5458
Log out
5559
</Button>
5660
<Button
5761
type="button"
5862
onClick={handleSignIn}
59-
loading={isLoading}
63+
loading={loginMutation.isPending}
6064
disabled={!cloudRegion}
6165
>
6266
Sign in

0 commit comments

Comments
 (0)