Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/desktop/src/main/handlers/authSyncHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface AuthSyncHandlerDeps {
encryptionService: EncryptionService | null;
localIdentity: LocalIdentity;
broadcastToWindows: BroadcastFn;
closeSettingsWindow?: () => void;
}

const EmailSchema = z.string().email().max(254);
Expand Down Expand Up @@ -117,7 +118,6 @@ export function registerAuthSyncHandlers(deps: AuthSyncHandlerDeps): void {
getCurrentUser: () => client.getCurrentUser(),
getAccessToken: () => storage.getAccessToken(),
clearTokens: () => storage.clearTokens(),
readLocal: () => localIdentity.read(),
}),
});

Expand All @@ -129,6 +129,8 @@ export function registerAuthSyncHandlers(deps: AuthSyncHandlerDeps): void {
sync?.stopAutoSync();
await storage.clearTokens();
await localIdentity.clear();
deps.broadcastToWindows('auth:signed-out');
deps.closeSettingsWindow?.();
return { success: true };
} catch (error) {
return {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
createMainWindow,
registerQuickCaptureShortcut,
registerWindowHandlers,
closeSettingsWindow,
} from './windows/register.js';
import {
applyDevelopmentModeFromSettings,
Expand Down Expand Up @@ -541,6 +542,7 @@ app
encryptionService,
localIdentity: new LocalIdentity(dataPaths.root),
broadcastToWindows,
closeSettingsWindow,
});
log.info(
{ encryptionAvailable: safeStorage.isEncryptionAvailable() },
Expand Down
9 changes: 2 additions & 7 deletions apps/desktop/src/main/services/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ describe('resolveSession', () => {
getCurrentUser: async () => user,
getAccessToken: async () => jwt({ sub: user.id, email: user.email }),
clearTokens: vi.fn(),
readLocal: async () => null,
});
expect(session).toEqual({ user });
});
Expand All @@ -44,7 +43,6 @@ describe('resolveSession', () => {
},
getAccessToken: async () => jwt({ sub: user.id, email: user.email }),
clearTokens,
readLocal: async () => null,
});
expect(session).toEqual({ user });
expect(clearTokens).not.toHaveBeenCalled();
Expand All @@ -59,21 +57,18 @@ describe('resolveSession', () => {
},
getAccessToken: async () => jwt({ sub: user.id, email: user.email }),
clearTokens,
readLocal: async () => null,
});
expect(session).toBeNull();
expect(clearTokens).toHaveBeenCalledOnce();
});

it('falls back to local identity when there are no tokens', async () => {
const local = { id: 'local', email: 'me@local' };
it('does not treat leftover local identity as a session', async () => {
const session = await resolveSession({
hasTokens: async () => false,
getCurrentUser: async () => user,
getAccessToken: async () => null,
clearTokens: vi.fn(),
readLocal: async () => local,
});
expect(session).toEqual({ user: local });
expect(session).toBeNull();
});
});
14 changes: 5 additions & 9 deletions apps/desktop/src/main/services/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*
* Tokens stay on disk unless the API says they are expired (401).
* A network blip must not log the user out.
* Leftover continue-locally identity is not a session — AuthGate requires
* a consumed magic-link (JWT in TokenStorage).
*/

import { ApiError } from './apiClient.js';
Expand Down Expand Up @@ -38,28 +40,22 @@ export async function resolveSession(deps: {
getCurrentUser: () => Promise<SessionUser>;
getAccessToken: () => Promise<string | null>;
clearTokens: () => Promise<void>;
readLocal: () => Promise<SessionUser | null>;
}): Promise<{ user: SessionUser } | null> {
const hasTokens = await deps.hasTokens();
if (!hasTokens) {
const local = await deps.readLocal();
return local ? { user: local } : null;
return null;
}

try {
return { user: await deps.getCurrentUser() };
} catch (error) {
if (isUnauthorizedError(error)) {
await deps.clearTokens();
const local = await deps.readLocal();
return local ? { user: local } : null;
return null;
}

const token = await deps.getAccessToken();
const fromJwt = token ? userFromAccessToken(token) : null;
if (fromJwt) return { user: fromJwt };

const local = await deps.readLocal();
return local ? { user: local } : null;
return fromJwt ? { user: fromJwt } : null;
}
}
10 changes: 8 additions & 2 deletions apps/desktop/src/main/windows/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { isClosable } from './closable.js';
import { createMainWindow } from './mainWindow.js';
import { createNoteWindow } from './noteWindow.js';
import { createQuickCaptureWindow } from './quickCaptureWindow.js';
import { createSettingsWindow } from './settingsWindow.js';
import { createSettingsWindow, closeSettingsWindow } from './settingsWindow.js';
import { applyFrosted, rememberFrosted } from './vibrancy.js';

export function registerWindowHandlers(): void {
Expand Down Expand Up @@ -66,4 +66,10 @@ export function registerQuickCaptureShortcut(): void {
}
}

export { createMainWindow, createNoteWindow, createQuickCaptureWindow, createSettingsWindow };
export {
createMainWindow,
createNoteWindow,
createQuickCaptureWindow,
createSettingsWindow,
closeSettingsWindow,
};
6 changes: 6 additions & 0 deletions apps/desktop/src/main/windows/settingsWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,9 @@ export function createSettingsWindow(): BrowserWindow {

return settingsWindow;
}

export function closeSettingsWindow(): void {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.close();
}
}
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
historyForward,
visitNote,
} from './utils/noteHistory';
import { useAuthSessionEvents } from './hooks/useAuthSessionEvents';
import { useDeepLinks } from './hooks/useDeepLinks';
import { useAutoSave } from './hooks/useAutoSave';
import { useNoteActions } from './hooks/useNoteActions';
Expand Down Expand Up @@ -76,6 +77,8 @@ function NotesApp() {
const isAuthenticated = useAuthStore(selectIsAuthenticated);
const isE2E = window.dripnex?.app?.isE2E?.() === true;

useAuthSessionEvents({ consumeMagicLink: true });

useEffect(() => {
void useAuthStore.getState().loadSession();
}, []);
Expand Down
62 changes: 62 additions & 0 deletions apps/desktop/src/renderer/hooks/useAuthSessionEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { applySignedOut, useAuthStore } from '../stores/authStore';

function authVerifyToken(value: unknown): string | null {
if (typeof value === 'string' && value.length > 0) return value;
if (!value || typeof value !== 'object') return null;
const rec = value as { kind?: unknown; token?: unknown };
if (rec.kind === 'auth-verify' && typeof rec.token === 'string' && rec.token.length > 0) {
return rec.token;
}
return null;
}

function consumeToken(token: string): void {
void useAuthStore
.getState()
.verifyToken(token)
.catch(error => {
console.error('Deep link auth verification failed:', error);
});
}

/**
* Settings is a separate renderer from the main window.
* Sign Out must clear that shell too, and AuthGate must consume magic-link
* tokens even before SignedInApp (and useDeepLinks) mounts.
*/
export function useAuthSessionEvents(options?: { consumeMagicLink?: boolean }): void {
const consumeMagicLink = options?.consumeMagicLink === true;

useEffect(() => {
const ipc = window.dripnex?.ipc;
if (!ipc?.on) return;

const offSignedOut = ipc.on('auth:signed-out', () => {
applySignedOut();
});

if (!consumeMagicLink) {
return () => {
offSignedOut();
};
}

const offVerify = ipc.on('auth:verify-token', (...args: unknown[]) => {
const token = authVerifyToken(args[0]);
if (token) consumeToken(token);
});

const onLocal = (event: Event) => {
const token = authVerifyToken((event as CustomEvent).detail);
if (token) consumeToken(token);
};
window.addEventListener('dripnex:open', onLocal);

return () => {
offSignedOut();
offVerify();
window.removeEventListener('dripnex:open', onLocal);
};
}, [consumeMagicLink]);
}
18 changes: 3 additions & 15 deletions apps/desktop/src/renderer/hooks/useDeepLinks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect } from 'react';
import { useAuthStore } from '../stores/authStore';
import type { DripnexDeepLink } from '../utils/parseDripnexUrl';
import { dispatchCommand } from './useCommandRegistry';
import { useNavigationActions } from './useNavigation';
Expand All @@ -18,15 +17,9 @@ export function useDeepLinks() {

useEffect(() => {
const apply = (link: DripnexDeepLink) => {
if (link.kind === 'auth-verify') {
void useAuthStore
.getState()
.verifyToken(link.token)
.catch(error => {
console.error('Deep link auth verification failed:', error);
});
return;
}
// Auth tokens are consumed in NotesApp via useAuthSessionEvents so
// AuthGate can verify before SignedInApp mounts.
if (link.kind === 'auth-verify') return;
if (link.kind === 'note') {
void dispatchCommand('app:open-note', {
noteId: link.noteId,
Expand All @@ -53,15 +46,10 @@ export function useDeepLinks() {
};

const offIpc = window.dripnex.ipc.on('app:deep-link', onIpc);
const offAuth = window.dripnex.ipc.on('auth:verify-token', (...args: unknown[]) => {
const token = typeof args[0] === 'string' ? args[0] : '';
if (token) apply({ kind: 'auth-verify', token });
});
window.addEventListener('dripnex:open', onLocal);

return () => {
offIpc();
offAuth();
window.removeEventListener('dripnex:open', onLocal);
};
}, [goToNotebook, goToTag]);
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/pages/settings/SettingsApp.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState } from 'react';
import { LayoutZone, useThemeOverrides } from '@dripnex/plugin-api';
import { useAppearanceSettings } from '../../hooks/useAppearanceSettings';
import { usePerformanceMode } from '../../hooks/usePerformanceMode';
import { useAuthSessionEvents } from '../../hooks/useAuthSessionEvents';
import { useOfficialThemes } from '../../hooks/useOfficialThemes';
import { usePerformanceMode } from '../../hooks/usePerformanceMode';
import { Toaster } from '../../ui/primitives';
import styles from './SettingsApp.module.css';
import { SettingsSidebar } from './components/SettingsSidebar';
Expand All @@ -29,6 +30,7 @@ export function SettingsApp() {
useOfficialThemes();
useThemeOverrides();
useAppearanceSettings();
useAuthSessionEvents();
const [activeSection, setActiveSection] = useState<SettingsSection>('general');

const renderSection = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
useAuthStore,
selectUser,
selectIsAuthenticated,
selectIsLoading,
selectError,
} from '../../../stores/authStore';
import {
Expand Down Expand Up @@ -53,7 +52,6 @@ function formatBytes(bytes: number): string {
export function AccountSection() {
const user = useAuthStore(selectUser);
const isAuthenticated = useAuthStore(selectIsAuthenticated);
const isLoading = useAuthStore(selectIsLoading);
const authError = useAuthStore(selectError);
const logout = useAuthStore(state => state.logout);
const loadSession = useAuthStore(state => state.loadSession);
Expand Down Expand Up @@ -118,7 +116,6 @@ export function AccountSection() {
setMessage(null);
try {
await logout();
setMessage('Signed out successfully');
} catch (error) {
setMessage(`Sign out failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
Expand Down Expand Up @@ -249,8 +246,7 @@ export function AccountSection() {
variant="danger"
size="sm"
icon={<Icon icon={LogOut} size={14} />}
onClick={handleSignOut}
disabled={isLoading}
onClick={() => void handleSignOut()}
>
Sign Out
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

const here = dirname(fileURLToPath(import.meta.url));
const account = readFileSync(join(here, '../AccountSection.tsx'), 'utf8');
const app = readFileSync(join(here, '../../../../App.tsx'), 'utf8');
const settingsApp = readFileSync(join(here, '../../SettingsApp.tsx'), 'utf8');
const handlers = readFileSync(
join(here, '../../../../../main/handlers/authSyncHandlers.ts'),
'utf8'
);

describe('Settings → Account Sign Out', () => {
it('calls a real logout and does not disable Sign Out while session hydrates', () => {
expect(account).toContain('handleSignOut');
expect(account).toContain('await logout()');
expect(account).toContain('Sign Out');
expect(account).not.toMatch(/disabled=\{isLoading\}/);
});

it('main and settings windows listen for auth:signed-out so AuthGate remounts', () => {
expect(app).toContain('useAuthSessionEvents({ consumeMagicLink: true })');
expect(settingsApp).toContain('useAuthSessionEvents()');
expect(handlers).toContain("broadcastToWindows('auth:signed-out')");
expect(handlers).toContain('clearTokens()');
expect(handlers).toContain('localIdentity.clear()');
});
});
Loading
Loading