mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-27 10:47:35 +00:00
wip
This commit is contained in:
parent
c27aa9e7ff
commit
c7c59d910c
@ -13,6 +13,7 @@
|
||||
"classnames": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"swr": "^2.2.4",
|
||||
"wouter": "^2.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -11,14 +11,12 @@ let unraidCsrfToken: string | undefined // required for unraid POST requests (#8
|
||||
// apiFetch adds the `api` prefix to the request URL,
|
||||
// so endpoint should be provided without the `api` prefix
|
||||
// (i.e. provide `/data` rather than `api/data`).
|
||||
export function apiFetch(
|
||||
export function apiFetch<T>(
|
||||
endpoint: string,
|
||||
method: "GET" | "POST" | "PATCH",
|
||||
body?: any,
|
||||
params?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
init?: RequestInit | undefined
|
||||
): Promise<T> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const nextParams = new URLSearchParams(params)
|
||||
const nextParams = new URLSearchParams()
|
||||
if (synoToken) {
|
||||
nextParams.set("SynoToken", synoToken)
|
||||
} else {
|
||||
@ -31,36 +29,43 @@ export function apiFetch(
|
||||
const url = `api${endpoint}${search ? `?${search}` : ""}`
|
||||
|
||||
var contentType: string
|
||||
if (unraidCsrfToken && method === "POST") {
|
||||
if (unraidCsrfToken && init?.method === "POST") {
|
||||
const params = new URLSearchParams()
|
||||
params.append("csrf_token", unraidCsrfToken)
|
||||
if (body) {
|
||||
params.append("ts_data", JSON.stringify(body))
|
||||
if (init.body) {
|
||||
params.append("ts_data", init.body.toString())
|
||||
}
|
||||
body = params.toString()
|
||||
init.body = params.toString()
|
||||
contentType = "application/x-www-form-urlencoded;charset=UTF-8"
|
||||
} else {
|
||||
body = body ? JSON.stringify(body) : undefined
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
method: method,
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": contentType,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body,
|
||||
}).then((r) => {
|
||||
updateCsrfToken(r)
|
||||
if (!r.ok) {
|
||||
return r.text().then((err) => {
|
||||
throw new Error(err)
|
||||
})
|
||||
}
|
||||
return r
|
||||
})
|
||||
.then((r) => {
|
||||
updateCsrfToken(r)
|
||||
if (!r.ok) {
|
||||
return r.text().then((err) => {
|
||||
throw new Error(err)
|
||||
})
|
||||
}
|
||||
return r
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
// TODO: MAYBE SET USING TOKEN HEADER
|
||||
if (r.IsUnraid && r.UnraidToken) {
|
||||
setUnraidCsrfToken(r.UnraidToken)
|
||||
}
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
function updateCsrfToken(r: Response) {
|
||||
@ -77,3 +82,36 @@ export function setSynoToken(token?: string) {
|
||||
export function setUnraidCsrfToken(token?: string) {
|
||||
unraidCsrfToken = token
|
||||
}
|
||||
|
||||
/**
|
||||
* Some fetch wrappers.
|
||||
*/
|
||||
|
||||
export async function getAuthSessionNew(): Promise<void> {
|
||||
const d = await apiFetch<{ authUrl: string }>("/auth/session/new", {
|
||||
method: "GET",
|
||||
})
|
||||
if (d.authUrl) {
|
||||
window.open(d.authUrl, "_blank")
|
||||
await apiFetch("/auth/session/wait", { method: "GET" })
|
||||
}
|
||||
// todo: still need catch for these, not using swr
|
||||
}
|
||||
|
||||
type PatchLocalPrefsData = {
|
||||
RunSSHSet?: boolean
|
||||
RunSSH?: boolean
|
||||
}
|
||||
|
||||
export async function patchLocalPrefs(p: PatchLocalPrefsData): Promise<void> {
|
||||
return apiFetch("/local/v0/prefs", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(p), // todo: annoying to do this for all...
|
||||
})
|
||||
// .then(onComplete)
|
||||
// .catch((err) => {
|
||||
// onComplete()
|
||||
// alert("Failed to update prefs")
|
||||
// throw err
|
||||
// })
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React, { useEffect } from "react"
|
||||
import { getAuthSessionNew } from "src/api"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
import LoginToggle from "src/components/login-toggle"
|
||||
import DeviceDetailsView from "src/components/views/device-details-view"
|
||||
@ -12,29 +13,24 @@ import SubnetRouterView from "src/components/views/subnet-router-view"
|
||||
import { UpdatingView } from "src/components/views/updating-view"
|
||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||
import useNodeData, { NodeData } from "src/hooks/node-data"
|
||||
import { useSWRConfig } from "swr"
|
||||
import { Link, Route, Router, Switch, useLocation } from "wouter"
|
||||
|
||||
export default function App() {
|
||||
const { data: auth, loading: loadingAuth, newSession } = useAuth()
|
||||
const { data: auth, loading: loadingAuth } = useAuth()
|
||||
|
||||
return (
|
||||
<main className="min-w-sm max-w-lg mx-auto py-14 px-5">
|
||||
{loadingAuth || !auth ? (
|
||||
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
|
||||
) : (
|
||||
<WebClient auth={auth} newSession={newSession} />
|
||||
<WebClient auth={auth} />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function WebClient({
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
function WebClient({ auth }: { auth: AuthResponse }) {
|
||||
const { data, refreshData, nodeUpdaters } = useNodeData()
|
||||
useEffect(() => {
|
||||
refreshData()
|
||||
@ -51,7 +47,7 @@ function WebClient({
|
||||
// Otherwise render the new web client.
|
||||
<>
|
||||
<Router base={data.URLPrefix}>
|
||||
<Header node={data} auth={auth} newSession={newSession} />
|
||||
<Header node={data} auth={auth} />
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<HomeView
|
||||
@ -93,15 +89,8 @@ function WebClient({
|
||||
)
|
||||
}
|
||||
|
||||
function Header({
|
||||
node,
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
function Header({ node, auth }: { node: NodeData; auth: AuthResponse }) {
|
||||
const { mutate } = useSWRConfig()
|
||||
const [loc] = useLocation()
|
||||
|
||||
return (
|
||||
@ -113,7 +102,11 @@ function Header({
|
||||
{node.DomainName}
|
||||
</div>
|
||||
</div>
|
||||
<LoginToggle node={node} auth={auth} newSession={newSession} />
|
||||
<LoginToggle
|
||||
node={node}
|
||||
auth={auth}
|
||||
newSession={() => getAuthSessionNew().then(() => mutate("/data"))}
|
||||
/>
|
||||
</div>
|
||||
{loc !== "/" && loc !== "/update" && (
|
||||
<Link
|
||||
|
@ -6,6 +6,7 @@ import * as Control from "src/components/control-components"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import Toggle from "src/ui/toggle"
|
||||
|
||||
// todo: htis fist
|
||||
export default function SSHView({
|
||||
readonly,
|
||||
node,
|
||||
|
@ -1,8 +1,9 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch, setSynoToken } from "src/api"
|
||||
import { useEffect } from "react"
|
||||
import { getAuthSessionNew, setSynoToken } from "src/api"
|
||||
import useSWR from "swr"
|
||||
|
||||
export enum AuthType {
|
||||
synology = "synology",
|
||||
@ -23,57 +24,29 @@ export type AuthResponse = {
|
||||
// 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, isLoading, mutate } = useSWR<AuthResponse>("/auth")
|
||||
|
||||
const loadAuth = useCallback(() => {
|
||||
setLoading(true)
|
||||
return apiFetch("/auth", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setData(d)
|
||||
switch ((d as AuthResponse).authNeeded) {
|
||||
case AuthType.synology:
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
setLoading(false)
|
||||
})
|
||||
break
|
||||
default:
|
||||
setLoading(false)
|
||||
}
|
||||
return d
|
||||
})
|
||||
.catch((error) => {
|
||||
setLoading(false)
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
return apiFetch("/auth/session/new", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d.authUrl) {
|
||||
window.open(d.authUrl, "_blank")
|
||||
return apiFetch("/auth/session/wait", "GET")
|
||||
}
|
||||
})
|
||||
.then(() => loadAuth())
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [loadAuth])
|
||||
useEffect(() => {
|
||||
if (data?.authNeeded === AuthType.synology) {
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
// Refresh auth reponse once synology
|
||||
// auth completed.
|
||||
mutate()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// TODO
|
||||
useEffect(() => {
|
||||
loadAuth().then((d) => {
|
||||
if (
|
||||
!d.canManageNode &&
|
||||
new URLSearchParams(window.location.search).get("check") === "now"
|
||||
) {
|
||||
newSession()
|
||||
getAuthSessionNew()
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@ -81,7 +54,6 @@ export default function useAuth() {
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
newSession,
|
||||
loading: isLoading || data?.authNeeded === AuthType.synology,
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { useMemo } from "react"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import useSWR from "swr"
|
||||
|
||||
export type ExitNode = {
|
||||
ID: string
|
||||
@ -28,17 +29,19 @@ export type ExitNodeGroup = {
|
||||
nodes: ExitNode[]
|
||||
}
|
||||
|
||||
export default function useExitNodes(tailnetName: string, filter?: string) {
|
||||
const [data, setData] = useState<ExitNode[]>([])
|
||||
export default function useExitNodes(filter?: string) {
|
||||
const { data: node } = useSWR<NodeData>("/data")
|
||||
const { data } = useSWR<ExitNode[]>("/exit-nodes") // TODO: PIPE BACK ERRORS, MAYBE SWR HAS SOMETHING GOOD
|
||||
// const [data, setData] = useState<ExitNode[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch("/exit-nodes", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((r) => setData(r))
|
||||
.catch((err) => {
|
||||
alert("Failed operation: " + err.message)
|
||||
})
|
||||
}, [])
|
||||
// useEffect(() => {
|
||||
// apiFetch("/exit-nodes", "GET")
|
||||
// .then((r) => r.json())
|
||||
// .then((r) => setData(r))
|
||||
// .catch((err) => {
|
||||
// alert("Failed operation: " + err.message)
|
||||
// })
|
||||
// }, [])
|
||||
|
||||
const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
|
||||
// First going through exit nodes and splitting them into two groups:
|
||||
@ -55,7 +58,7 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
|
||||
// Only Mullvad exit nodes have locations filled.
|
||||
tailnetNodes.push({
|
||||
...n,
|
||||
Name: trimDNSSuffix(n.Name, tailnetName),
|
||||
Name: trimDNSSuffix(n.Name, node?.TailnetName || ""),
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -70,7 +73,7 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
|
||||
tailnetNodesSorted: tailnetNodes.sort(compareByName),
|
||||
locationNodesMap: locationNodes,
|
||||
}
|
||||
}, [data, tailnetName])
|
||||
}, [data, node?.TailnetName])
|
||||
|
||||
const hasFilter = Boolean(filter)
|
||||
|
||||
|
@ -1,10 +1,11 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { apiFetch, setUnraidCsrfToken } from "src/api"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
|
||||
import { VersionInfo } from "src/hooks/self-update"
|
||||
import useSWR from "swr"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
@ -93,21 +94,10 @@ type RoutesPOSTData = {
|
||||
|
||||
// useNodeData returns basic data about the current node.
|
||||
export default function useNodeData() {
|
||||
const [data, setData] = useState<NodeData>()
|
||||
const { data, mutate } = useSWR<NodeData>("/data") // TODO: USE GLOBAL MUTATE!!!
|
||||
// const [data, setData] = useState<NodeData>()
|
||||
const [isPosting, setIsPosting] = useState<boolean>(false)
|
||||
|
||||
const refreshData = useCallback(
|
||||
() =>
|
||||
apiFetch("/data", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d: NodeData) => {
|
||||
setData(d)
|
||||
setUnraidCsrfToken(d.IsUnraid ? d.UnraidToken : undefined)
|
||||
})
|
||||
.catch((error) => console.error(error)),
|
||||
[setData]
|
||||
)
|
||||
|
||||
const prefsPATCH = useCallback(
|
||||
(d: PrefsPATCHData) => {
|
||||
setIsPosting(true)
|
||||
@ -120,12 +110,12 @@ export default function useNodeData() {
|
||||
// then make the prefs PATCH. If the request fails,
|
||||
// data will be updated to it's previous value in
|
||||
// onComplete below.
|
||||
setData(optimisticUpdates)
|
||||
mutate(optimisticUpdates, { revalidate: false })
|
||||
}
|
||||
|
||||
const onComplete = () => {
|
||||
setIsPosting(false)
|
||||
refreshData() // refresh data after PATCH finishes
|
||||
mutate() // refresh data after PATCH finishes
|
||||
}
|
||||
|
||||
return apiFetch("/local/v0/prefs", "PATCH", d)
|
||||
@ -136,7 +126,7 @@ export default function useNodeData() {
|
||||
throw err
|
||||
})
|
||||
},
|
||||
[setIsPosting, refreshData, setData, data]
|
||||
[data, mutate]
|
||||
)
|
||||
|
||||
const routesPOST = useCallback(
|
||||
@ -144,7 +134,7 @@ export default function useNodeData() {
|
||||
setIsPosting(true)
|
||||
const onComplete = () => {
|
||||
setIsPosting(false)
|
||||
refreshData() // refresh data after POST finishes
|
||||
mutate() // refresh data after POST finishes
|
||||
}
|
||||
|
||||
return apiFetch("/routes", "POST", d)
|
||||
@ -155,27 +145,27 @@ export default function useNodeData() {
|
||||
throw err
|
||||
})
|
||||
},
|
||||
[setIsPosting, refreshData]
|
||||
[setIsPosting, mutate]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
// Initial data load.
|
||||
refreshData()
|
||||
// useEffect(
|
||||
// () => {
|
||||
// // Initial data load.
|
||||
// refreshData()
|
||||
|
||||
// Refresh on browser tab focus.
|
||||
const onVisibilityChange = () => {
|
||||
document.visibilityState === "visible" && refreshData()
|
||||
}
|
||||
window.addEventListener("visibilitychange", onVisibilityChange)
|
||||
return () => {
|
||||
// Cleanup browser tab listener.
|
||||
window.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
}
|
||||
},
|
||||
// Run once.
|
||||
[refreshData]
|
||||
)
|
||||
// // Refresh on browser tab focus.
|
||||
// const onVisibilityChange = () => {
|
||||
// document.visibilityState === "visible" && refreshData()
|
||||
// }
|
||||
// window.addEventListener("visibilitychange", onVisibilityChange)
|
||||
// return () => {
|
||||
// // Cleanup browser tab listener.
|
||||
// window.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
// }
|
||||
// },
|
||||
// // Run once.
|
||||
// [refreshData]
|
||||
// )
|
||||
|
||||
const nodeUpdaters: NodeUpdaters = useMemo(
|
||||
() => ({
|
||||
|
@ -11,6 +11,8 @@
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import App from "src/components/app"
|
||||
import { SWRConfig } from "swr"
|
||||
import { apiFetch } from "./api"
|
||||
|
||||
declare var window: any
|
||||
// This is used to determine if the react client is built.
|
||||
@ -25,6 +27,19 @@ const root = createRoot(rootEl)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: apiFetch,
|
||||
onError: (err, _) => {
|
||||
// TODO: toast on error instead?
|
||||
if (err.message) {
|
||||
alert(`Request failed: ${err.message}`)
|
||||
}
|
||||
console.error(err)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</SWRConfig>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
@ -2587,6 +2587,11 @@ classnames@^2.3.1:
|
||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
|
||||
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
|
||||
|
||||
client-only@^0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
|
||||
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
|
||||
|
||||
color-convert@^1.9.0:
|
||||
version "1.9.3"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
||||
@ -4701,6 +4706,14 @@ svg-parser@^2.0.4:
|
||||
resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5"
|
||||
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
|
||||
|
||||
swr@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.4.tgz#03ec4c56019902fbdc904d78544bd7a9a6fa3f07"
|
||||
integrity sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==
|
||||
dependencies:
|
||||
client-only "^0.0.1"
|
||||
use-sync-external-store "^1.2.0"
|
||||
|
||||
tailwindcss@^3.3.3:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf"
|
||||
@ -4953,7 +4966,7 @@ use-sidecar@^1.1.2:
|
||||
detect-node-es "^1.1.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sync-external-store@^1.0.0:
|
||||
use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
||||
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
||||
|
Loading…
x
Reference in New Issue
Block a user