chore(sync): merge main into dev
# Conflicts: # apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx
This commit is contained in:
commit
aea74f80a7
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "folocli",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"description": "Folo CLI for terminal workflows and automation",
|
||||
"author": "Folo Team",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("./browser-login", () => ({
|
||||
loginWithBrowser: vi.fn(),
|
||||
resolveBrowserLoginToken: vi.fn(),
|
||||
}))
|
||||
|
||||
const { loginWithBrowser, resolveBrowserLoginToken } = await import("./browser-login")
|
||||
const { resolveLoginToken } = await import("./commands/auth")
|
||||
|
||||
describe("resolveLoginToken", () => {
|
||||
it("exchanges a provided one-time token into a session token", async () => {
|
||||
vi.mocked(resolveBrowserLoginToken).mockResolvedValueOnce("session-token")
|
||||
|
||||
await expect(
|
||||
resolveLoginToken({
|
||||
inputToken: "one-time-token",
|
||||
apiUrl: "https://api.folo.is",
|
||||
timeoutMs: 180_000,
|
||||
onStatus: vi.fn(),
|
||||
}),
|
||||
).resolves.toBe("session-token")
|
||||
|
||||
expect(resolveBrowserLoginToken).toHaveBeenCalledWith("https://api.folo.is", "one-time-token")
|
||||
})
|
||||
|
||||
it("falls back to browser login when no token is provided", async () => {
|
||||
vi.mocked(loginWithBrowser).mockResolvedValueOnce({
|
||||
token: "browser-session-token",
|
||||
callbackUrl: "http://127.0.0.1/callback",
|
||||
loginUrl: "https://app.folo.is/login",
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveLoginToken({
|
||||
apiUrl: "https://api.folo.is",
|
||||
timeoutMs: 180_000,
|
||||
onStatus: vi.fn(),
|
||||
}),
|
||||
).resolves.toBe("browser-session-token")
|
||||
})
|
||||
})
|
||||
|
|
@ -56,12 +56,15 @@ describe("browser login helpers", () => {
|
|||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
session: { token: "session-token" },
|
||||
user: { id: "user-1" },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"set-cookie":
|
||||
"__Secure-better-auth.session_token=session-token; Path=/; HttpOnly; Secure; SameSite=None",
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
@ -72,6 +75,49 @@ describe("browser login helpers", () => {
|
|||
expect(token).toBe("session-token")
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.folo.is/better-auth/one-time-token/apply",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token: "one-time-token" }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("falls back to verify when apply endpoint is unavailable", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 404,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
session: { token: "session-token" },
|
||||
user: { id: "user-1" },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
)
|
||||
vi.stubGlobal("fetch", fetchMock)
|
||||
|
||||
const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "one-time-token")
|
||||
|
||||
expect(token).toBe("session-token")
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://api.folo.is/better-auth/one-time-token/apply",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token: "one-time-token" }),
|
||||
}),
|
||||
)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://api.folo.is/better-auth/one-time-token/verify",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { CLIError } from "./output"
|
|||
const LOCAL_CALLBACK_HOST = "127.0.0.1"
|
||||
const LOCAL_CALLBACK_PATH = "/callback"
|
||||
const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000
|
||||
const ONE_TIME_TOKEN_APPLY_PATH = "/better-auth/one-time-token/apply"
|
||||
const ONE_TIME_TOKEN_VERIFY_PATH = "/better-auth/one-time-token/verify"
|
||||
const SESSION_CHECK_PATH = "/better-auth/get-session"
|
||||
|
||||
|
|
@ -114,6 +115,38 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
const readSetCookieValues = (response: Response): string[] => {
|
||||
if (typeof response.headers.getSetCookie === "function") {
|
||||
return response.headers.getSetCookie()
|
||||
}
|
||||
|
||||
const setCookie = response.headers.get("set-cookie")
|
||||
return setCookie ? [setCookie] : []
|
||||
}
|
||||
|
||||
const extractSessionTokenFromSetCookie = (setCookieValues: string[]): string | undefined => {
|
||||
for (const setCookie of setCookieValues) {
|
||||
const match = setCookie.match(/(?:__Secure-)?better-auth\.session_token=([^;]+)/)
|
||||
if (match?.[1]) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const extractSessionTokenFromBody = (data: unknown): string | undefined => {
|
||||
if (!isRecord(data)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (isRecord(data.session) && typeof data.session.token === "string") {
|
||||
return data.session.token
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const extractErrorMessage = async (response: Response): Promise<string | undefined> => {
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
|
||||
|
|
@ -147,42 +180,80 @@ const hasValidSessionToken = async (apiUrl: string, token: string): Promise<bool
|
|||
}
|
||||
|
||||
export const resolveBrowserLoginToken = async (apiUrl: string, token: string): Promise<string> => {
|
||||
const applyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_APPLY_PATH)
|
||||
const verifyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_VERIFY_PATH)
|
||||
|
||||
let response: Response
|
||||
const requestBody = JSON.stringify({ token })
|
||||
|
||||
let response: Response | undefined
|
||||
let errorMessage: string | undefined
|
||||
try {
|
||||
response = await fetch(verifyUrl, {
|
||||
response = await fetch(applyUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
body: requestBody,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new CLIError(
|
||||
"NETWORK_ERROR",
|
||||
`Failed to verify browser login token: ${(error as Error).message}`,
|
||||
`Failed to apply browser login token: ${(error as Error).message}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as unknown
|
||||
const sessionToken =
|
||||
isRecord(data) && isRecord(data.session) && typeof data.session.token === "string"
|
||||
? data.session.token
|
||||
: undefined
|
||||
extractSessionTokenFromSetCookie(readSetCookieValues(response)) ??
|
||||
extractSessionTokenFromBody(data)
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new CLIError(
|
||||
"UNAUTHORIZED",
|
||||
"Browser login verification succeeded without returning a session token.",
|
||||
"Browser login token apply succeeded without returning a session token.",
|
||||
)
|
||||
}
|
||||
|
||||
return sessionToken
|
||||
}
|
||||
|
||||
const errorMessage = await extractErrorMessage(response)
|
||||
errorMessage = await extractErrorMessage(response)
|
||||
|
||||
if (response.status === 404) {
|
||||
try {
|
||||
response = await fetch(verifyUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: requestBody,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new CLIError(
|
||||
"NETWORK_ERROR",
|
||||
`Failed to verify browser login token: ${(error as Error).message}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as unknown
|
||||
const sessionToken =
|
||||
extractSessionTokenFromSetCookie(readSetCookieValues(response)) ??
|
||||
extractSessionTokenFromBody(data)
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new CLIError(
|
||||
"UNAUTHORIZED",
|
||||
"Browser login verification succeeded without returning a session token.",
|
||||
)
|
||||
}
|
||||
|
||||
return sessionToken
|
||||
}
|
||||
|
||||
errorMessage = await extractErrorMessage(response)
|
||||
}
|
||||
|
||||
try {
|
||||
if (await hasValidSessionToken(apiUrl, token)) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const readString = (value: unknown): string | undefined => {
|
|||
return typeof value === "string" && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
const normalizeToken = (token: string | undefined) => {
|
||||
export const normalizeToken = (token: string | undefined) => {
|
||||
if (!token || !token.includes("%")) {
|
||||
return token
|
||||
}
|
||||
|
|
@ -42,6 +42,61 @@ export interface CommandContext {
|
|||
token?: string
|
||||
}
|
||||
|
||||
export interface CLIAuthSession {
|
||||
user?: Record<string, unknown>
|
||||
session?: Record<string, unknown>
|
||||
role?: unknown
|
||||
roleEndAt?: unknown
|
||||
feedSubscriptionLimit?: unknown
|
||||
rsshubSubscriptionLimit?: unknown
|
||||
}
|
||||
|
||||
const readSessionErrorMessage = async (response: Response): Promise<string | undefined> => {
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await response.json().catch(() => null)) as Record<string, unknown> | null
|
||||
return typeof data?.message === "string" ? data.message : undefined
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => "")
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
export const fetchAuthSession = async ({
|
||||
apiUrl,
|
||||
token,
|
||||
verbose = false,
|
||||
}: {
|
||||
apiUrl: string
|
||||
token: string
|
||||
verbose?: boolean
|
||||
}): Promise<CLIAuthSession> => {
|
||||
const requestUrl = `${apiUrl}/better-auth/get-session`
|
||||
if (verbose) {
|
||||
console.error(`[request] GET ${requestUrl}`)
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (verbose) {
|
||||
console.error(`[response] GET ${requestUrl} -> ${response.status}`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await readSessionErrorMessage(response)
|
||||
throw new CLIError("UNAUTHORIZED", message || "Token is invalid or expired.")
|
||||
}
|
||||
|
||||
return (await response.json()) as CLIAuthSession
|
||||
}
|
||||
|
||||
export const getGlobalOptions = (command: Command): GlobalOptions => {
|
||||
const options = command.optsWithGlobals() as Record<string, unknown>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,36 @@
|
|||
import type { Command } from "commander"
|
||||
|
||||
import { parsePositiveInt } from "../args"
|
||||
import { loginWithBrowser } from "../browser-login"
|
||||
import { getGlobalOptions } from "../client"
|
||||
import { loginWithBrowser, resolveBrowserLoginToken } from "../browser-login"
|
||||
import { fetchAuthSession, getGlobalOptions, normalizeToken } from "../client"
|
||||
import { runCommand } from "../command"
|
||||
import { clearToken, getConfigPath, updateConfig } from "../config"
|
||||
import { CLIError } from "../output"
|
||||
|
||||
export const resolveLoginToken = async ({
|
||||
inputToken,
|
||||
apiUrl,
|
||||
timeoutMs,
|
||||
onStatus,
|
||||
}: {
|
||||
inputToken?: string
|
||||
apiUrl: string
|
||||
timeoutMs: number
|
||||
onStatus: (message: string) => void
|
||||
}) => {
|
||||
if (inputToken) {
|
||||
return await resolveBrowserLoginToken(apiUrl, inputToken)
|
||||
}
|
||||
|
||||
const browserLogin = await loginWithBrowser({
|
||||
apiUrl,
|
||||
timeoutMs,
|
||||
onStatus,
|
||||
})
|
||||
|
||||
return browserLogin.token
|
||||
}
|
||||
|
||||
interface AuthLoginOptions {
|
||||
token?: string
|
||||
timeout?: number
|
||||
|
|
@ -16,21 +40,22 @@ const runLoginAction = async function (this: Command, options: AuthLoginOptions)
|
|||
await runCommand(
|
||||
this,
|
||||
async ({ client, options: globalOptions }) => {
|
||||
let token = options.token ?? getGlobalOptions(this).token
|
||||
if (!token) {
|
||||
const timeoutMs = (options.timeout ?? 180) * 1000
|
||||
const browserLogin = await loginWithBrowser({
|
||||
apiUrl: globalOptions.apiUrl,
|
||||
timeoutMs,
|
||||
onStatus: (message) => {
|
||||
console.error(`[auth] ${message}`)
|
||||
},
|
||||
})
|
||||
token = browserLogin.token
|
||||
}
|
||||
const resolvedToken = await resolveLoginToken({
|
||||
inputToken: options.token ?? getGlobalOptions(this).token,
|
||||
apiUrl: globalOptions.apiUrl,
|
||||
timeoutMs: (options.timeout ?? 180) * 1000,
|
||||
onStatus: (message) => {
|
||||
console.error(`[auth] ${message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const token = normalizeToken(resolvedToken) ?? resolvedToken
|
||||
client.setAuthToken(token)
|
||||
const session = await client.api.auth.getSession()
|
||||
const session = await fetchAuthSession({
|
||||
apiUrl: globalOptions.apiUrl,
|
||||
token,
|
||||
verbose: globalOptions.verbose,
|
||||
})
|
||||
|
||||
if (!session.user || !session.session) {
|
||||
throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.")
|
||||
|
|
@ -66,8 +91,16 @@ const runLogoutAction = async function (this: Command) {
|
|||
}
|
||||
|
||||
const runWhoamiAction = async function (this: Command) {
|
||||
await runCommand(this, async ({ client }) => {
|
||||
const session = await client.api.auth.getSession()
|
||||
await runCommand(this, async ({ token, options: globalOptions }) => {
|
||||
if (!token) {
|
||||
throw new CLIError("UNAUTHORIZED", "Missing token.")
|
||||
}
|
||||
|
||||
const session = await fetchAuthSession({
|
||||
apiUrl: globalOptions.apiUrl,
|
||||
token,
|
||||
verbose: globalOptions.verbose,
|
||||
})
|
||||
if (!session.user || !session.session) {
|
||||
throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.")
|
||||
}
|
||||
|
|
@ -87,7 +120,7 @@ const registerLoginCommand = (program: Command, name: string, description: strin
|
|||
program
|
||||
.command(name)
|
||||
.description(description)
|
||||
.option("--token <token>", "Session token from Folo")
|
||||
.option("--token <token>", "Session or one-time token from Folo")
|
||||
.option(
|
||||
"--timeout <seconds>",
|
||||
"Browser login timeout in seconds (default: 180)",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
# What's new in v1.5.0
|
||||
|
||||
## Shiny new things
|
||||
|
||||
## Improvements
|
||||
|
||||
- Polished authentication, wallet, notifications, and discover surfaces
|
||||
- Kept the AI chat input within the viewport during longer conversations
|
||||
|
||||
## No longer broken
|
||||
|
||||
- Fixed Electron sign-in for accounts using two-factor authentication
|
||||
- Fixed PDF export through Electron IPC
|
||||
- Fixed auth origin headers and restored renderer API requests
|
||||
- Fixed theme preference persistence
|
||||
- Fixed hovered unread entries disappearing in poor network conditions
|
||||
- Fixed Obsidian vault selection and export reliability on macOS with native folder picking
|
||||
- Fixed Android video playback changing the original audio pitch
|
||||
|
||||
## Thanks
|
||||
|
||||
Special thanks to volunteer contributor @Eumenides-K for their valuable contributions
|
||||
|
|
@ -9,16 +9,19 @@ import { WindowManager } from "~/manager/window"
|
|||
|
||||
import {
|
||||
buildManagedAuthCookieHeader,
|
||||
buildManagedAuthCookieHeaderFromSetCookieHeader,
|
||||
getManagedAuthCookies,
|
||||
persistManagedAuthCookiesFromSetCookieHeader,
|
||||
} from "../../lib/auth-cookies"
|
||||
import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../../lib/cli-session-sync"
|
||||
import { getCliSessionToken, syncSessionToCliConfig } from "../../lib/cli-session-sync"
|
||||
import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user"
|
||||
import { logger } from "../../logger"
|
||||
|
||||
export class AuthService extends IpcService {
|
||||
static override readonly groupName = "auth"
|
||||
|
||||
private pendingTwoFactorCookieHeader: string | null = null
|
||||
|
||||
private getAuthRequestHeaders(additionalHeaders?: Record<string, string>) {
|
||||
return {
|
||||
...createDesktopAPIHeaders({ version: PKG.version }),
|
||||
|
|
@ -61,6 +64,7 @@ export class AuthService extends IpcService {
|
|||
}
|
||||
|
||||
private async clearSessionToken(): Promise<void> {
|
||||
this.pendingTwoFactorCookieHeader = null
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
if (!mainWindow) {
|
||||
return
|
||||
|
|
@ -93,7 +97,12 @@ export class AuthService extends IpcService {
|
|||
.json()
|
||||
.catch(async () => ({ message: await response.text() }))) as Record<string, unknown>
|
||||
|
||||
const setCookie = response.headers.get("set-cookie") || ""
|
||||
const setCookieValues =
|
||||
typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []
|
||||
const setCookie =
|
||||
setCookieValues.length > 0
|
||||
? setCookieValues.join(", ")
|
||||
: response.headers.get("set-cookie") || ""
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
if (response.ok && setCookie && mainWindow) {
|
||||
await persistManagedAuthCookiesFromSetCookieHeader({
|
||||
|
|
@ -102,6 +111,13 @@ export class AuthService extends IpcService {
|
|||
setCookieHeader: setCookie,
|
||||
})
|
||||
}
|
||||
|
||||
const pendingTwoFactorCookieHeader = buildManagedAuthCookieHeaderFromSetCookieHeader(setCookie)
|
||||
this.pendingTwoFactorCookieHeader =
|
||||
response.ok && typeof data.twoFactorRedirect === "boolean" && data.twoFactorRedirect
|
||||
? pendingTwoFactorCookieHeader || null
|
||||
: null
|
||||
|
||||
const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/)
|
||||
const sessionToken = sessionCookieMatch?.[1] ?? null
|
||||
const token = typeof data.token === "string" ? data.token : null
|
||||
|
|
@ -126,11 +142,13 @@ export class AuthService extends IpcService {
|
|||
}
|
||||
|
||||
@IpcMethod()
|
||||
async sessionChanged(_context: IpcContext): Promise<void> {
|
||||
async sessionChanged(_context: IpcContext, preferredToken?: string): Promise<void> {
|
||||
await updateNotificationsToken()
|
||||
|
||||
// Sync the current desktop session to the npm CLI login.
|
||||
const token = await getSessionTokenFromCookies()
|
||||
const token = await getCliSessionToken({
|
||||
preferredToken,
|
||||
})
|
||||
await syncSessionToCliConfig(token).catch((err) => {
|
||||
logger.error("Failed to sync session to CLI config:", err)
|
||||
})
|
||||
|
|
@ -168,14 +186,16 @@ export class AuthService extends IpcService {
|
|||
payload: { code: string; trustDevice?: boolean; headers?: Record<string, string> },
|
||||
) {
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
const cookieHeader = mainWindow
|
||||
? buildManagedAuthCookieHeader(
|
||||
await getManagedAuthCookies({
|
||||
apiURL: env.VITE_API_URL,
|
||||
session: mainWindow.webContents.session,
|
||||
}),
|
||||
)
|
||||
: ""
|
||||
const cookieHeader =
|
||||
this.pendingTwoFactorCookieHeader ||
|
||||
(mainWindow
|
||||
? buildManagedAuthCookieHeader(
|
||||
await getManagedAuthCookies({
|
||||
apiURL: env.VITE_API_URL,
|
||||
session: mainWindow.webContents.session,
|
||||
}),
|
||||
)
|
||||
: "")
|
||||
|
||||
const response = await fetch(`${env.VITE_API_URL}/better-auth/two-factor/verify-totp`, {
|
||||
method: "POST",
|
||||
|
|
@ -193,7 +213,10 @@ export class AuthService extends IpcService {
|
|||
const data = (await response
|
||||
.json()
|
||||
.catch(async () => ({ message: await response.text() }))) as Record<string, unknown>
|
||||
const setCookie = response.headers.get("set-cookie") || ""
|
||||
const setCookie =
|
||||
typeof response.headers.getSetCookie === "function"
|
||||
? response.headers.getSetCookie().join(", ")
|
||||
: response.headers.get("set-cookie") || ""
|
||||
if (response.ok && setCookie && mainWindow) {
|
||||
await persistManagedAuthCookiesFromSetCookieHeader({
|
||||
apiURL: env.VITE_API_URL,
|
||||
|
|
@ -202,6 +225,22 @@ export class AuthService extends IpcService {
|
|||
})
|
||||
}
|
||||
|
||||
const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/)
|
||||
const sessionTokenFromCookie = sessionCookieMatch?.[1] ?? null
|
||||
const sessionTokenFromBody =
|
||||
data.session && typeof data.session === "object" && "token" in data.session
|
||||
? (data.session as { token?: unknown }).token
|
||||
: null
|
||||
const sessionToken =
|
||||
typeof sessionTokenFromBody === "string" ? sessionTokenFromBody : sessionTokenFromCookie
|
||||
if (typeof sessionToken === "string") {
|
||||
data.sessionToken = sessionToken
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
this.pendingTwoFactorCookieHeader = null
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
error: response.ok
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getCliConfigPath,
|
||||
getCliInstallCommand,
|
||||
getCliLoginCommand,
|
||||
getCliSessionToken,
|
||||
getSessionTokenFromCookies,
|
||||
isCliRunnerAvailable,
|
||||
readCliConfig,
|
||||
|
|
@ -45,13 +46,18 @@ export class CliService extends IpcService {
|
|||
}
|
||||
|
||||
@IpcMethod()
|
||||
async installCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> {
|
||||
async installCli(
|
||||
_context: IpcContext,
|
||||
preferredToken?: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!(await isCliRunnerAvailable())) {
|
||||
return { success: false, error: "npx is not available. Install Node.js and npm first." }
|
||||
}
|
||||
|
||||
const token = await getSessionTokenFromCookies()
|
||||
const token = await getCliSessionToken({
|
||||
preferredToken,
|
||||
})
|
||||
if (!token) {
|
||||
return { success: false, error: "Sign in to Folo Desktop first." }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"
|
|||
|
||||
import {
|
||||
buildManagedAuthCookieHeader,
|
||||
buildManagedAuthCookieHeaderFromSetCookieHeader,
|
||||
getManagedAuthCookieNames,
|
||||
persistManagedAuthCookiesFromSetCookieHeader,
|
||||
} from "./auth-cookies"
|
||||
|
|
@ -25,6 +26,19 @@ describe("auth cookies", () => {
|
|||
expect(getManagedAuthCookieNames()).toContain("two_factor")
|
||||
})
|
||||
|
||||
it("keeps prefixed two-factor cookies from a set-cookie header", () => {
|
||||
const header = buildManagedAuthCookieHeaderFromSetCookieHeader(
|
||||
[
|
||||
"__Secure-better-auth.two_factor=signed-two-factor; Path=/; HttpOnly; Secure; SameSite=Lax",
|
||||
"better-auth.last_used_login_method=email; Path=/; HttpOnly; Secure; SameSite=Lax",
|
||||
].join(", "),
|
||||
)
|
||||
|
||||
expect(header).toBe(
|
||||
"__Secure-better-auth.two_factor=signed-two-factor; better-auth.last_used_login_method=email",
|
||||
)
|
||||
})
|
||||
|
||||
it("persists managed auth cookies and removes expired ones from a set-cookie header", async () => {
|
||||
const set = vi.fn().mockImplementation(async () => {})
|
||||
const remove = vi.fn().mockImplementation(async () => {})
|
||||
|
|
|
|||
|
|
@ -7,8 +7,14 @@ const MANAGED_AUTH_COOKIE_NAMES = [
|
|||
"better-auth.session_data",
|
||||
"better-auth.last_used_login_method",
|
||||
"dont_remember",
|
||||
"__Secure-better-auth.dont_remember",
|
||||
"better-auth.dont_remember",
|
||||
"trust_device",
|
||||
"__Secure-better-auth.trust_device",
|
||||
"better-auth.trust_device",
|
||||
"two_factor",
|
||||
"__Secure-better-auth.two_factor",
|
||||
"better-auth.two_factor",
|
||||
] as const
|
||||
|
||||
type ManagedAuthCookieName = (typeof MANAGED_AUTH_COOKIE_NAMES)[number]
|
||||
|
|
@ -161,6 +167,18 @@ export const getManagedAuthCookieNames = () => {
|
|||
return [...MANAGED_AUTH_COOKIE_NAMES]
|
||||
}
|
||||
|
||||
export const buildManagedAuthCookieHeaderFromSetCookieHeader = (setCookieHeader: string) => {
|
||||
if (!setCookieHeader.trim()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parseSetCookieHeader(setCookieHeader)
|
||||
.filter((cookie) => isManagedAuthCookie(cookie.name))
|
||||
.filter((cookie) => !shouldRemoveCookie(cookie))
|
||||
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
export const buildManagedAuthCookieHeader = (cookies: Array<Pick<Cookie, "name" | "value">>) => {
|
||||
return cookies
|
||||
.filter((cookie) => isManagedAuthCookie(cookie.name))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
export const resolveCliSessionToken = ({
|
||||
preferredToken,
|
||||
cookieToken,
|
||||
}: {
|
||||
preferredToken?: string
|
||||
cookieToken?: string
|
||||
}) => cookieToken || preferredToken
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolveCliSessionToken } from "./cli-login-token"
|
||||
|
||||
describe("resolveCliSessionToken", () => {
|
||||
it("prefers the desktop session cookie token", () => {
|
||||
expect(
|
||||
resolveCliSessionToken({
|
||||
preferredToken: "one-time-token",
|
||||
cookieToken: "session-token",
|
||||
}),
|
||||
).toBe("session-token")
|
||||
})
|
||||
|
||||
it("falls back to the preferred token when no cookie token exists", () => {
|
||||
expect(
|
||||
resolveCliSessionToken({
|
||||
preferredToken: "session-token",
|
||||
}),
|
||||
).toBe("session-token")
|
||||
})
|
||||
|
||||
it("returns undefined when neither token is available", () => {
|
||||
expect(resolveCliSessionToken({})).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -4,12 +4,16 @@ import { homedir } from "node:os"
|
|||
import { promisify } from "node:util"
|
||||
|
||||
import { env } from "@follow/shared/env.desktop"
|
||||
import { createAuthRequestOriginHeaders, createDesktopAPIHeaders } from "@follow/utils/headers"
|
||||
import PKG from "@pkg"
|
||||
import { join } from "pathe"
|
||||
|
||||
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
|
||||
import { WindowManager } from "~/manager/window"
|
||||
|
||||
import { logger } from "../logger"
|
||||
import { buildManagedAuthCookieHeader, getManagedAuthCookies } from "./auth-cookies"
|
||||
import { resolveCliSessionToken } from "./cli-login-token"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
export const CLI_NPM_PACKAGE_NAME = "folocli"
|
||||
|
|
@ -18,6 +22,12 @@ const CLI_CONFIG_DIR = join(homedir(), ".folo")
|
|||
const CLI_CONFIG_PATH = join(CLI_CONFIG_DIR, "config.json")
|
||||
const getNpxCommand = () => (process.platform === "win32" ? "npx.cmd" : "npx")
|
||||
|
||||
const getCliSyncRequestHeaders = (additionalHeaders?: Record<string, string>) => ({
|
||||
...createDesktopAPIHeaders({ version: PKG.version }),
|
||||
...createAuthRequestOriginHeaders(env.VITE_WEB_URL),
|
||||
...additionalHeaders,
|
||||
})
|
||||
|
||||
export interface CliConfig {
|
||||
token?: string
|
||||
apiUrl?: string
|
||||
|
|
@ -41,8 +51,7 @@ export const getCliConfigPath = () => CLI_CONFIG_PATH
|
|||
|
||||
export const getCliInstallCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} --help`
|
||||
|
||||
export const getCliLoginCommand = () =>
|
||||
`npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token <session-token>`
|
||||
export const getCliLoginCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token <token>`
|
||||
|
||||
const runCliCommand = async (args: string[]) => {
|
||||
await execFileAsync(getNpxCommand(), ["--yes", CLI_NPX_PACKAGE_SPEC, ...args], {
|
||||
|
|
@ -90,6 +99,93 @@ export const getSessionTokenFromCookies = async (): Promise<string | undefined>
|
|||
return sessionCookie?.value
|
||||
}
|
||||
|
||||
const generateOneTimeTokenFromCurrentSession = async (): Promise<string | undefined> => {
|
||||
const window = WindowManager.getMainWindow()
|
||||
if (!window) return undefined
|
||||
|
||||
const cookieHeader = buildManagedAuthCookieHeader(
|
||||
await getManagedAuthCookies({
|
||||
apiURL: env.VITE_API_URL,
|
||||
session: window.webContents.session,
|
||||
}),
|
||||
)
|
||||
|
||||
if (!cookieHeader) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/generate`, {
|
||||
method: "GET",
|
||||
headers: getCliSyncRequestHeaders({
|
||||
Cookie: cookieHeader,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const data = (await response.json().catch(() => null)) as { token?: unknown } | null
|
||||
return typeof data?.token === "string" ? data.token : undefined
|
||||
}
|
||||
|
||||
const resolveSessionTokenFromOneTimeToken = async (
|
||||
oneTimeToken: string,
|
||||
): Promise<string | undefined> => {
|
||||
const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/apply`, {
|
||||
method: "POST",
|
||||
headers: getCliSyncRequestHeaders({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ token: oneTimeToken }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const setCookieValues =
|
||||
typeof response.headers.getSetCookie === "function"
|
||||
? response.headers.getSetCookie()
|
||||
: ([response.headers.get("set-cookie")].filter(Boolean) as string[])
|
||||
for (const setCookie of setCookieValues) {
|
||||
const match = setCookie.match(/(?:__Secure-)?better-auth\.session_token=([^;]+)/)
|
||||
if (match?.[1]) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json().catch(() => null)) as { session?: { token?: unknown } } | null
|
||||
return typeof data?.session?.token === "string" ? data.session.token : undefined
|
||||
}
|
||||
|
||||
export const getCliSessionToken = async ({
|
||||
preferredToken,
|
||||
}: {
|
||||
preferredToken?: string
|
||||
} = {}): Promise<string | undefined> => {
|
||||
const oneTimeToken = await generateOneTimeTokenFromCurrentSession().catch((error) => {
|
||||
logger.error("Failed to generate one-time token for CLI sync:", error)
|
||||
return
|
||||
})
|
||||
|
||||
if (oneTimeToken) {
|
||||
const sessionToken = await resolveSessionTokenFromOneTimeToken(oneTimeToken).catch((error) => {
|
||||
logger.error("Failed to resolve session token from one-time token:", error)
|
||||
return
|
||||
})
|
||||
|
||||
if (sessionToken) {
|
||||
return sessionToken
|
||||
}
|
||||
}
|
||||
|
||||
return resolveCliSessionToken({
|
||||
preferredToken,
|
||||
cookieToken: await getSessionTokenFromCookies(),
|
||||
})
|
||||
}
|
||||
|
||||
export const syncSessionToCliConfig = async (token?: string): Promise<void> => {
|
||||
if (token) {
|
||||
const config = await readCliConfig()
|
||||
|
|
|
|||
|
|
@ -37,25 +37,44 @@ const getAuthTokenFromResult = (result: unknown) => {
|
|||
return null
|
||||
}
|
||||
|
||||
if ("token" in result && typeof result.token === "string") {
|
||||
return result.token
|
||||
}
|
||||
|
||||
if ("sessionToken" in result && typeof result.sessionToken === "string") {
|
||||
return result.sessionToken
|
||||
}
|
||||
|
||||
if ("token" in result && typeof result.token === "string") {
|
||||
return result.token
|
||||
if ("session" in result && result.session && typeof result.session === "object") {
|
||||
const { token } = result.session as { token?: unknown }
|
||||
if (typeof token === "string") {
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
"data" in result &&
|
||||
result.data &&
|
||||
typeof result.data === "object" &&
|
||||
("sessionToken" in result.data || "token" in result.data)
|
||||
("sessionToken" in result.data || "token" in result.data || "session" in result.data)
|
||||
) {
|
||||
const { sessionToken, token } = result.data as { sessionToken?: unknown; token?: unknown }
|
||||
if (typeof sessionToken === "string") {
|
||||
return sessionToken
|
||||
const { sessionToken, token, session } = result.data as {
|
||||
sessionToken?: unknown
|
||||
token?: unknown
|
||||
session?: { token?: unknown } | unknown
|
||||
}
|
||||
return typeof token === "string" ? token : null
|
||||
if (typeof token === "string") {
|
||||
return token
|
||||
}
|
||||
if (
|
||||
session &&
|
||||
typeof session === "object" &&
|
||||
"token" in session &&
|
||||
typeof session.token === "string"
|
||||
) {
|
||||
return session.token
|
||||
}
|
||||
return typeof sessionToken === "string" ? sessionToken : null
|
||||
}
|
||||
|
||||
return null
|
||||
|
|
@ -74,7 +93,15 @@ const normalizeElectronAuthResult = (result: unknown): ElectronAuthResult => {
|
|||
return {}
|
||||
}
|
||||
|
||||
return result as ElectronAuthResult
|
||||
const normalized = result as ElectronAuthResult & Record<string, unknown>
|
||||
if ("data" in normalized || "error" in normalized) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return {
|
||||
data: normalized,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const setElectronSessionToken = async (token: string) => {
|
||||
|
|
|
|||
|
|
@ -283,6 +283,7 @@ const useRegisterObsidianCommands = () => {
|
|||
author: string
|
||||
publishedAt: string
|
||||
vaultPath: string
|
||||
description?: string
|
||||
}) => {
|
||||
return await ipcServices?.integration.saveToObsidian(data)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export function TOTPForm({
|
|||
const updateMutation = useMutation({
|
||||
mutationFn: onSubmitMutationFn,
|
||||
onError: (error) => {
|
||||
const { code } = getFetchErrorInfo(error)
|
||||
const { code, message } = getFetchErrorInfo(error)
|
||||
if (error.message === "invalid two factor authentication" || code === 4007) {
|
||||
form.resetField("code")
|
||||
form.setError("code", {
|
||||
|
|
@ -90,7 +90,10 @@ export function TOTPForm({
|
|||
form.setFocus("code")
|
||||
}, 10)
|
||||
controls.start("shake")
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(message || t("profile.totp_code.invalid"))
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
|
@ -135,6 +138,11 @@ export function TOTPForm({
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="text-right">
|
||||
<Button type="submit" isLoading={updateMutation.isPending}>
|
||||
{t("profile.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import type { SVGProps } from "react"
|
||||
|
||||
export const Android2CuteReIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path fill="currentColor" fillOpacity="0.01" d="M24 0v24H0V0z" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 13.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0m7 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="2"
|
||||
d="m6 5 1.5 3m9 0L18 5m-9 8.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm7 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0ZM4.833 18h14.334C20.179 18 21 17.18 21 16.167V16a9 9 0 0 0-9-9v0a9 9 0 0 0-9 9v.167C3 17.179 3.82 18 4.833 18Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
|
@ -4,10 +4,38 @@ import { useCallback, useEffect, useState } from "react"
|
|||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { oneTimeToken } from "~/lib/auth"
|
||||
import { ipcServices } from "~/lib/client"
|
||||
import { getAuthSessionToken } from "~/lib/client-session"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
|
||||
import { SettingSectionTitle } from "../section"
|
||||
|
||||
const getOneTimeTokenFromResult = (result: unknown) => {
|
||||
if (!result || typeof result !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
if ("token" in result && typeof result.token === "string") {
|
||||
return result.token
|
||||
}
|
||||
|
||||
if (
|
||||
"data" in result &&
|
||||
result.data &&
|
||||
typeof result.data === "object" &&
|
||||
"token" in result.data &&
|
||||
typeof result.data.token === "string"
|
||||
) {
|
||||
return result.data.token
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const LATEST_WITH_NPX_COMMAND = "npx --yes folocli@latest --help"
|
||||
const AGENT_PROMPT = "Read https://api.folo.is/skill.md and follow the instructions to use Folo."
|
||||
|
||||
export const SettingCli = () => {
|
||||
interface CliInstallStatus {
|
||||
connected: boolean
|
||||
|
|
@ -36,7 +64,10 @@ export const SettingCli = () => {
|
|||
const handleInstall = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await ipcServices?.cli.installCli()
|
||||
const generatedOneTimeToken = getOneTimeTokenFromResult(await oneTimeToken.generate())
|
||||
const result = await ipcServices?.cli.installCli(
|
||||
generatedOneTimeToken ?? getAuthSessionToken() ?? undefined,
|
||||
)
|
||||
if (result?.success) {
|
||||
toast.success(t("cli.install_success"))
|
||||
} else {
|
||||
|
|
@ -102,24 +133,39 @@ export const SettingCli = () => {
|
|||
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
{t("cli.package")}
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
<span>RUN LATEST WITH NPX</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-accent transition-opacity hover:opacity-80"
|
||||
onClick={() => {
|
||||
void copyToClipboard(LATEST_WITH_NPX_COMMAND)
|
||||
toast.success("Command copied")
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-copy-2-cute-re text-sm" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<code className="text-sm font-medium">{status.packageName}</code>
|
||||
<code className="block break-all text-sm">{LATEST_WITH_NPX_COMMAND}</code>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
{t("cli.global_install")}
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
<span>AGENT PROMPT</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-accent transition-opacity hover:opacity-80"
|
||||
onClick={() => {
|
||||
void copyToClipboard(AGENT_PROMPT)
|
||||
toast.success("Prompt copied")
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-copy-2-cute-re text-sm" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<code className="block break-all text-sm">{status.installCommand}</code>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
{t("cli.desktop_sync")}
|
||||
</div>
|
||||
<code className="block break-all text-sm">{status.loginCommand}</code>
|
||||
<p className="text-sm text-text-secondary">{AGENT_PROMPT}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
} from "@follow/components/ui/platform-icon/icons.js"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import type { FC } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
|
||||
import { Android2CuteReIcon } from "~/modules/settings/icons/Android2CuteReIcon"
|
||||
import { SettingCli } from "~/modules/settings/tabs/cli"
|
||||
import { SettingsTitle } from "~/modules/settings/title"
|
||||
import { defineSettingPageData } from "~/modules/settings/utils"
|
||||
|
||||
const iconName = "i-mgc-terminal-cute-re"
|
||||
const priority = (1000 << 1) + 25
|
||||
|
||||
export const loader = defineSettingPageData({
|
||||
icon: iconName,
|
||||
icon: <Android2CuteReIcon />,
|
||||
headerIcon: <Android2CuteReIcon className="size-5 text-accent" />,
|
||||
name: "titles.cli",
|
||||
priority,
|
||||
hideIf: () => !IN_ELECTRON,
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ export const useSession = (options?: { enabled?: boolean }) => {
|
|||
|
||||
export const handleSessionChanges = () => {
|
||||
setLoginModalShow(false)
|
||||
ipcServices?.auth.sessionChanged()
|
||||
const authSessionToken = getAuthSessionToken()
|
||||
ipcServices?.auth.sessionChanged(authSessionToken ?? undefined)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "Folo",
|
||||
"type": "module",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"description": "Follow everything in one place",
|
||||
"author": "Folo Team",
|
||||
|
|
@ -94,5 +94,5 @@
|
|||
"vite-tsconfig-paths": "6.1.1"
|
||||
},
|
||||
"productName": "Folo",
|
||||
"mainHash": "0c464fca7c98fd4b42abba743abb1cd590e216043987acf4f6d1e10392ce0e57"
|
||||
"mainHash": "4778cc1cb43d08ed2a5edf391f0d22694641dc15eb27eaefb45bfa09ff176091"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue