Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 2 deletions apps/desktop/docs/ROUTING_REFACTOR_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,15 +443,15 @@ function AuthenticatedLayout() {

return (
<CollectionsProvider>
<OrganizationsProvider>

<Background />
<AppFrame>
<Outlet /> {/* workspace, tasks, workspaces, settings render here */}
</AppFrame>
<SetupConfigModal />
<NewWorkspaceModal />
<WorkspaceInitEffects />
</OrganizationsProvider>

</CollectionsProvider>
);
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^5.5.0",
"better-auth": "^1.4.9",
"better-sqlite3": "12.5.0",
"bindings": "^1.5.0",
"clsx": "^2.1.1",
Expand Down
102 changes: 67 additions & 35 deletions apps/desktop/src/lib/trpc/routers/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,94 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { AUTH_PROVIDERS } from "@superset/shared/constants";
import { observable } from "@trpc/server/observable";
import { type AuthSession, authService } from "main/lib/auth";
import { shell } from "electron";
import { env } from "main/env.main";
import { z } from "zod";
import { publicProcedure, router } from "../..";

/** Auth state emitted by onAuthState subscription */
export type AuthState = (AuthSession & { token: string | null }) | null;
import {
authEvents,
loadToken,
saveToken,
stateStore,
TOKEN_FILE,
} from "./utils/auth-functions";

export const createAuthRouter = () => {
return router({
onAuthState: publicProcedure.subscription(() => {
return observable<AuthState>((emit) => {
const emitCurrent = () => {
const sessionData = authService.getSession();
const token = authService.getAccessToken();
getStoredToken: publicProcedure.query(async () => {
return await loadToken();
}),

if (!sessionData) {
emit.next(null);
return;
persistToken: publicProcedure
.input(
z.object({
token: z.string(),
expiresAt: z.string(),
}),
)
.mutation(async ({ input }) => {
await saveToken(input);
return { success: true };
}),

onTokenChanged: publicProcedure.subscription(() => {
return observable<{ token: string; expiresAt: string } | null>((emit) => {
loadToken().then((initial) => {
if (initial.token && initial.expiresAt) {
emit.next({ token: initial.token, expiresAt: initial.expiresAt });
}
});

emit.next({ ...sessionData, token });
const handler = (data: { token: string; expiresAt: string }) => {
emit.next(data);
};

emitCurrent();

const sessionHandler = () => {
emitCurrent();
};
const stateHandler = () => {
emitCurrent();
};

authService.on("session-changed", sessionHandler);
authService.on("state-changed", stateHandler);
authEvents.on("token-saved", handler);

return () => {
authService.off("session-changed", sessionHandler);
authService.off("state-changed", stateHandler);
authEvents.off("token-saved", handler);
};
});
}),

setActiveOrganization: publicProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input }) => {
await authService.setActiveOrganization(input.organizationId);
return { success: true };
}),

/**
* Start OAuth sign-in flow.
* Opens browser for OAuth, token delivered via deep link callback.
*/
signIn: publicProcedure
.input(z.object({ provider: z.enum(AUTH_PROVIDERS) }))
.mutation(async ({ input }) => {
return authService.signIn(input.provider);
try {
const state = crypto.randomBytes(32).toString("base64url");
stateStore.set(state, Date.now());

// Clean up old states (older than 10 minutes)
const tenMinutesAgo = Date.now() - 10 * 60 * 1000;
for (const [s, ts] of stateStore) {
if (ts < tenMinutesAgo) stateStore.delete(s);
}

const connectUrl = new URL(
`${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/connect`,
);
connectUrl.searchParams.set("provider", input.provider);
connectUrl.searchParams.set("state", state);
await shell.openExternal(connectUrl.toString());
return { success: true };
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to open browser",
};
}
}),

signOut: publicProcedure.mutation(async () => {
await authService.signOut();
try {
await fs.unlink(TOKEN_FILE);
} catch {}
return { success: true };
}),
Comment on lines 88 to 93
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent error swallowing in signOut.

The empty catch {} block silently swallows errors when unlinking the token file. Per coding guidelines, never swallow errors silently—at minimum log them with context. While the file may legitimately not exist (e.g., already signed out), other errors (permissions, disk issues) should be logged.

Suggested fix
 		signOut: publicProcedure.mutation(async () => {
 			try {
 				await fs.unlink(TOKEN_FILE);
-			} catch {}
+			} catch (err) {
+				// ENOENT is expected if already signed out; log other errors
+				if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
+					console.error("[auth/signOut] Failed to remove token file:", err);
+				}
+			}
 			return { success: true };
 		}),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
signOut: publicProcedure.mutation(async () => {
await authService.signOut();
try {
await fs.unlink(TOKEN_FILE);
} catch {}
return { success: true };
}),
signOut: publicProcedure.mutation(async () => {
try {
await fs.unlink(TOKEN_FILE);
} catch (err) {
// ENOENT is expected if already signed out; log other errors
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
console.error("[auth/signOut] Failed to remove token file:", err);
}
}
return { success: true };
}),
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/auth/index.ts` around lines 88 - 93, The
signOut publicProcedure currently swallows errors in the empty catch when
calling fs.unlink(TOKEN_FILE); update the catch to inspect the caught error: if
err.code === 'ENOENT' quietly ignore (file already absent), otherwise log the
error with context (include TOKEN_FILE and the error) using the module's logger
(or console.error if no logger exists) so permission/disk errors are visible;
keep the behavior of returning { success: true } after handling/logging.

});
Expand Down
95 changes: 95 additions & 0 deletions apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import { join } from "node:path";
import { PROTOCOL_SCHEMES } from "@superset/shared/constants";
import { SUPERSET_HOME_DIR } from "main/lib/app-environment";
import { decrypt, encrypt } from "./crypto-storage";

interface StoredAuth {
token: string;
expiresAt: string;
}

export const TOKEN_FILE = join(SUPERSET_HOME_DIR, "auth-token.enc");
export const stateStore = new Map<string, number>();

/**
* Event emitter for auth-related events.
* Used by tRPC subscription to notify renderer of token changes.
*/
export const authEvents = new EventEmitter();

/**
* Load token from encrypted disk storage.
*/
export async function loadToken(): Promise<{
token: string | null;
expiresAt: string | null;
}> {
try {
const data = decrypt(await fs.readFile(TOKEN_FILE));
const parsed: StoredAuth = JSON.parse(data);
return { token: parsed.token, expiresAt: parsed.expiresAt };
} catch {
return { token: null, expiresAt: null };
}
}

/**
* Persist token to encrypted disk storage and notify subscribers.
*/
export async function saveToken({
token,
expiresAt,
}: {
token: string;
expiresAt: string;
}): Promise<void> {
const storedAuth: StoredAuth = { token, expiresAt };
await fs.writeFile(TOKEN_FILE, encrypt(JSON.stringify(storedAuth)));
authEvents.emit("token-saved", { token, expiresAt });
}

/**
* Handle OAuth callback from deep link.
* Validates CSRF state and saves token.
*/
export async function handleAuthCallback(params: {
token: string;
expiresAt: string;
state: string;
}): Promise<{ success: boolean; error?: string }> {
if (!stateStore.has(params.state)) {
return { success: false, error: "Invalid or expired auth session" };
}
stateStore.delete(params.state);

await saveToken({ token: params.token, expiresAt: params.expiresAt });

return { success: true };
}

/**
* Parse and validate auth deep link URL.
*/
export function parseAuthDeepLink(
url: string,
): { token: string; expiresAt: string; state: string } | null {
try {
const parsed = new URL(url);
const validProtocols = [
`${PROTOCOL_SCHEMES.PROD}:`,
`${PROTOCOL_SCHEMES.DEV}:`,
];
if (!validProtocols.includes(parsed.protocol)) return null;
if (parsed.host !== "auth" || parsed.pathname !== "/callback") return null;

const token = parsed.searchParams.get("token");
const expiresAt = parsed.searchParams.get("expiresAt");
const state = parsed.searchParams.get("state");
if (!token || !expiresAt || !state) return null;
return { token, expiresAt, state };
} catch {
return null;
}
}
4 changes: 0 additions & 4 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ import { createPortsRouter } from "./ports";
import { createProjectsRouter } from "./projects";
import { createRingtoneRouter } from "./ringtone";
import { createSettingsRouter } from "./settings";
import { createTasksRouter } from "./tasks";
import { createTerminalRouter } from "./terminal";
import { createUiStateRouter } from "./ui-state";
import { createUserRouter } from "./user";
import { createWindowRouter } from "./window";
import { createWorkspacesRouter } from "./workspaces";

Expand All @@ -25,7 +23,6 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
analytics: createAnalyticsRouter(),
auth: createAuthRouter(),
autoUpdate: createAutoUpdateRouter(),
user: createUserRouter(),
window: createWindowRouter(getWindow),
projects: createProjectsRouter(getWindow),
workspaces: createWorkspacesRouter(),
Expand All @@ -40,7 +37,6 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
config: createConfigRouter(),
uiState: createUiStateRouter(),
ringtone: createRingtoneRouter(),
tasks: createTasksRouter(),
});
};

Expand Down
31 changes: 0 additions & 31 deletions apps/desktop/src/lib/trpc/routers/tasks/index.ts

This file was deleted.

22 changes: 0 additions & 22 deletions apps/desktop/src/lib/trpc/routers/user/index.ts

This file was deleted.

17 changes: 10 additions & 7 deletions apps/desktop/src/lib/window-loader.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import type { BrowserWindow } from "electron";
import { PORTS } from "shared/constants";
import { PORTS, PROTOCOL_SCHEME } from "shared/constants";
import { env } from "shared/env.shared";

/** Window IDs defined in the router configuration */
type WindowId = "main" | "about";

/**
* Load an Electron window with the appropriate URL for TanStack Router.
* Uses hash-based routing for compatibility with Electron's file:// protocol.
* Uses hash-based routing for compatibility with Electron's custom protocol.
*
* - Development (NODE_ENV=development): loads from Vite dev server at http://localhost:PORT/#/
* - Preview/Production: loads from built HTML file with hash routing (#/)
* - Development: loads from Vite dev server at http://localhost:PORT/#/
* - Production: loads from custom protocol at superset://app/index.html#/
* (provides stable origin for Better Auth CORS)
*/
export function registerRoute(props: {
id: WindowId;
Expand All @@ -25,8 +26,10 @@ export function registerRoute(props: {
const url = `http://localhost:${PORTS.VITE_DEV_SERVER}/#/`;
props.browserWindow.loadURL(url);
} else {
// Preview or Production: load from file with hash routing
// TanStack Router uses hash-based routing, so we always start at #/
props.browserWindow.loadFile(props.htmlFile, { hash: "/" });
// Production: load from custom protocol with hash routing
// Origin becomes: superset://app (trusted by Better Auth)
const fileName = props.htmlFile.split("/").pop() || "index.html";
const url = `${PROTOCOL_SCHEME}://app/${fileName}#/`;
props.browserWindow.loadURL(url);
}
}
Loading