summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/web/package.json1
-rw-r--r--client/web/src/api.ts80
-rw-r--r--client/web/src/components/app.tsx33
-rw-r--r--client/web/src/components/views/ssh-view.tsx1
-rw-r--r--client/web/src/hooks/auth.ts66
-rw-r--r--client/web/src/hooks/exit-nodes.ts31
-rw-r--r--client/web/src/hooks/node-data.ts64
-rw-r--r--client/web/src/index.tsx17
-rw-r--r--client/web/yarn.lock15
9 files changed, 167 insertions, 141 deletions
diff --git a/client/web/package.json b/client/web/package.json
index 81494e4d4..dc317b757 100644
--- a/client/web/package.json
+++ b/client/web/package.json
@@ -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": {
diff --git a/client/web/src/api.ts b/client/web/src/api.ts
index b6c0c4415..5114197d8 100644
--- a/client/web/src/api.ts
+++ b/client/web/src/api.ts
@@ -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
+ // })
+}
diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx
index 8fcdaf6f6..2320d07c5 100644
--- a/client/web/src/components/app.tsx
+++ b/client/web/src/components/app.tsx
@@ -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
diff --git a/client/web/src/components/views/ssh-view.tsx b/client/web/src/components/views/ssh-view.tsx
index 1a80edf99..11aaf8225 100644
--- a/client/web/src/components/views/ssh-view.tsx
+++ b/client/web/src/components/views/ssh-view.tsx
@@ -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,
diff --git a/client/web/src/hooks/auth.ts b/client/web/src/hooks/auth.ts
index f8537681a..9b2eca15e 100644
--- a/client/web/src/hooks/auth.ts
+++ b/client/web/src/hooks/auth.ts
@@ -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,
}
}
diff --git a/client/web/src/hooks/exit-nodes.ts b/client/web/src/hooks/exit-nodes.ts
index 9f08e0be8..85547f5c3 100644
--- a/client/web/src/hooks/exit-nodes.ts
+++ b/client/web/src/hooks/exit-nodes.ts
@@ -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)
diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts
index 981ff9bcc..39e9c7cc5 100644
--- a/client/web/src/hooks/node-data.ts
+++ b/client/web/src/hooks/node-data.ts
@@ -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(
() => ({
diff --git a/client/web/src/index.tsx b/client/web/src/index.tsx
index ef5a65a44..9f252d5b0 100644
--- a/client/web/src/index.tsx
+++ b/client/web/src/index.tsx
@@ -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>
)
diff --git a/client/web/yarn.lock b/client/web/yarn.lock
index efcabd8ab..c699f2e8d 100644
--- a/client/web/yarn.lock
+++ b/client/web/yarn.lock
@@ -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==