client/web: signal need to wait for auth across tabs
This amends the session creation and auth status querying logic of the device UI backend. On creation of new browser sessions we now store a PendingAuth flag as part of the session that indicates a pending auth process that needs to be awaited. On auth status queries, the server initiates a polling for the auth result if it finds this flag to be true. Once the polling is completes, the flag is set to false. Why this change was necessary: with regular browser settings, the device UI frontend opens the control auth URL in a new tab and starts polling for the results of the auth flow in the current tab. With certain browser settings (that we still want to support), however, the auth URL opens in the same tab, thus aborting the subsequent call to auth/session/wait that initiates the polling, and preventing successful registration of the auth results in the session status. The new logic ensures the polling happens on the next call to /api/auth in these kinds of scenarios. In addition to ensuring the auth wait happens, we now also revalidate the auth state whenever an open tab regains focus, so that auth changes effected in one tab propagate to other tabs without the need to refresh. This improves the experience for all users of the web client when they've got multiple tabs open, regardless of their browser settings. Fixes #11905 Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
This commit is contained in:
@@ -123,7 +123,10 @@ export function useAPI() {
|
||||
return apiFetch<{ url?: string }>("/up", "POST", t.data)
|
||||
.then((d) => d.url && window.open(d.url, "_blank")) // "up" login step
|
||||
.then(() => incrementMetric("web_client_node_connect"))
|
||||
.then(() => mutate("/data"))
|
||||
.then(() => {
|
||||
mutate("/data")
|
||||
mutate("/auth")
|
||||
})
|
||||
.catch(handlePostError("Failed to login"))
|
||||
|
||||
/**
|
||||
@@ -134,9 +137,9 @@ export function useAPI() {
|
||||
// For logout, must increment metric before running api call,
|
||||
// as tailscaled will be unreachable after the call completes.
|
||||
incrementMetric("web_client_node_disconnect")
|
||||
return apiFetch("/local/v0/logout", "POST").catch(
|
||||
handlePostError("Failed to logout")
|
||||
)
|
||||
return apiFetch("/local/v0/logout", "POST")
|
||||
.then(() => mutate("/auth"))
|
||||
.catch(handlePostError("Failed to logout"))
|
||||
|
||||
/**
|
||||
* "new-auth-session" handles creating a new check mode session to
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch, setSynoToken } from "src/api"
|
||||
import useSWR from "swr"
|
||||
|
||||
export type AuthResponse = {
|
||||
serverMode: AuthServerMode
|
||||
@@ -49,33 +50,26 @@ export function hasAnyEditCapabilities(auth: AuthResponse): boolean {
|
||||
* useAuth reports and refreshes Tailscale auth status for the web client.
|
||||
*/
|
||||
export default function useAuth() {
|
||||
const [data, setData] = useState<AuthResponse>()
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const { data, error, mutate } = useSWR<AuthResponse>("/auth")
|
||||
const [ranSynoAuth, setRanSynoAuth] = useState<boolean>(false)
|
||||
|
||||
const loadAuth = useCallback(() => {
|
||||
setLoading(true)
|
||||
return apiFetch<AuthResponse>("/auth", "GET")
|
||||
.then((d) => {
|
||||
setData(d)
|
||||
if (d.needsSynoAuth) {
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
setRanSynoAuth(true)
|
||||
setLoading(false)
|
||||
})
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
return d
|
||||
})
|
||||
.catch((error) => {
|
||||
setLoading(false)
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
const loading = !data && !error
|
||||
|
||||
// Start Synology auth flow if needed.
|
||||
useEffect(() => {
|
||||
if (data?.needsSynoAuth && !ranSynoAuth) {
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
setRanSynoAuth(true)
|
||||
mutate()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Synology auth error:", error)
|
||||
})
|
||||
}
|
||||
}, [data?.needsSynoAuth, ranSynoAuth, mutate])
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
return apiFetch<{ authUrl?: string }>("/auth/session/new", "GET")
|
||||
@@ -86,34 +80,26 @@ export default function useAuth() {
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
loadAuth()
|
||||
mutate()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [loadAuth])
|
||||
}, [mutate])
|
||||
|
||||
// Start regular auth flow.
|
||||
useEffect(() => {
|
||||
loadAuth().then((d) => {
|
||||
if (!d) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!d.authorized &&
|
||||
hasAnyEditCapabilities(d) &&
|
||||
// Start auth flow immediately if browser has requested it.
|
||||
new URLSearchParams(window.location.search).get("check") === "now"
|
||||
) {
|
||||
newSession()
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
const needsAuth =
|
||||
data &&
|
||||
!loading &&
|
||||
!data.authorized &&
|
||||
hasAnyEditCapabilities(data) &&
|
||||
new URLSearchParams(window.location.search).get("check") === "now"
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth() // Refresh auth state after syno auth runs
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ranSynoAuth])
|
||||
if (needsAuth) {
|
||||
newSession()
|
||||
}
|
||||
}, [data, loading, newSession])
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
Reference in New Issue
Block a user