mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-01-18 03:28:19 +01:00
Release 202412292127
This commit is contained in:
31
ui/src/routes/adopt.tsx
Normal file
31
ui/src/routes/adopt.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { LoaderFunctionArgs, redirect } from "react-router-dom";
|
||||
import api from "../api";
|
||||
|
||||
const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
const tempToken = searchParams.get("tempToken");
|
||||
const deviceId = searchParams.get("deviceId");
|
||||
const oidcGoogle = searchParams.get("oidcGoogle");
|
||||
const clientId = searchParams.get("clientId");
|
||||
|
||||
const res = await api.POST(
|
||||
`${import.meta.env.VITE_SIGNAL_API}/cloud/register`,
|
||||
{
|
||||
token: tempToken,
|
||||
cloudApi: import.meta.env.VITE_CLOUD_API,
|
||||
oidcGoogle,
|
||||
clientId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error("Failed to register device");
|
||||
return redirect(import.meta.env.VITE_CLOUD_APP + `/devices/${deviceId}/setup`);
|
||||
};
|
||||
|
||||
export default function AdoptRoute() {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
AdoptRoute.loader = loader;
|
||||
141
ui/src/routes/devices.$id.deregister.tsx
Normal file
141
ui/src/routes/devices.$id.deregister.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
redirect,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
} from "react-router-dom";
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { CardHeader } from "@components/CardHeader";
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import { User } from "@/hooks/stores";
|
||||
import { checkAuth } from "@/main";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
|
||||
|
||||
interface LoaderData {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
user: User;
|
||||
}
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { deviceId } = Object.fromEntries(await request.formData());
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${deviceId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
} catch (e) {
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
|
||||
return redirect("/devices");
|
||||
};
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const user = await checkAuth();
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
const { device } = (await res.json()) as {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
};
|
||||
|
||||
return { device, user };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { devices: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export default function DevicesIdDeregister() {
|
||||
const { device, user } = useLoaderData() as LoaderData;
|
||||
const error = useActionData() as { message: string };
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<DashboardNavbar
|
||||
isLoggedIn={!!user}
|
||||
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
/>
|
||||
|
||||
<div className="w-full h-full">
|
||||
<div className="mt-4">
|
||||
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
|
||||
<div className="space-y-4">
|
||||
<LinkButton
|
||||
size="SM"
|
||||
theme="blank"
|
||||
LeadingIcon={ChevronLeftIcon}
|
||||
text="Back to Devices"
|
||||
to="/devices"
|
||||
/>
|
||||
<Card className="max-w-3xl p-6">
|
||||
<div className="max-w-xl space-y-4">
|
||||
<CardHeader
|
||||
headline={`Deregister ${device.name || device.id} from your cloud account`}
|
||||
description={
|
||||
<>
|
||||
This will remove the device from your cloud account and revoke
|
||||
remote access to it.
|
||||
<br />
|
||||
Please note that local access will still be possible
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Fieldset>
|
||||
<Form method="POST" className="max-w-sm space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<input name="deviceId" type="hidden" value={device.id} />
|
||||
<LinkButton
|
||||
size="MD"
|
||||
theme="light"
|
||||
to="/devices"
|
||||
text="Cancel"
|
||||
textAlign="center"
|
||||
/>
|
||||
<Button
|
||||
size="MD"
|
||||
theme="danger"
|
||||
type="submit"
|
||||
text="Deregister from Cloud"
|
||||
textAlign="center"
|
||||
/>
|
||||
</div>
|
||||
{error?.message && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{error?.message}
|
||||
</p>
|
||||
)}
|
||||
</Form>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DevicesIdDeregister.loader = loader;
|
||||
DevicesIdDeregister.action = action;
|
||||
134
ui/src/routes/devices.$id.rename.tsx
Normal file
134
ui/src/routes/devices.$id.rename.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
redirect,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
} from "react-router-dom";
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
|
||||
import Card from "@components/Card";
|
||||
import { CardHeader } from "@components/CardHeader";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import { User } from "@/hooks/stores";
|
||||
import { checkAuth } from "@/main";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import api from "../api";
|
||||
|
||||
interface LoaderData {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
user: User;
|
||||
}
|
||||
|
||||
const action = async ({ params, request }: ActionFunctionArgs) => {
|
||||
const { id } = params;
|
||||
const { name } = Object.fromEntries(await request.formData());
|
||||
|
||||
if (!name || name === "") {
|
||||
return { message: "Please specify a name" };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.PUT(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
|
||||
name,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
} catch (e) {
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
|
||||
return redirect("/devices");
|
||||
};
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const user = await checkAuth();
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
const { device } = (await res.json()) as {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
};
|
||||
|
||||
return { device, user };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { devices: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export default function DeviceIdRename() {
|
||||
const { device, user } = useLoaderData() as LoaderData;
|
||||
const error = useActionData() as { message: string };
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<DashboardNavbar
|
||||
isLoggedIn={!!user}
|
||||
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
/>
|
||||
|
||||
<div className="w-full h-full">
|
||||
<div className="mt-4">
|
||||
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
|
||||
<div className="space-y-4">
|
||||
<LinkButton
|
||||
size="SM"
|
||||
theme="blank"
|
||||
LeadingIcon={ChevronLeftIcon}
|
||||
text="Back to Devices"
|
||||
to="/devices"
|
||||
/>
|
||||
<Card className="max-w-3xl p-6">
|
||||
<div className="space-y-4">
|
||||
<CardHeader
|
||||
headline={`Rename ${device.name || device.id}`}
|
||||
description="Properly name your device to easily identify it."
|
||||
/>
|
||||
|
||||
<Fieldset>
|
||||
<Form method="POST" className="max-w-sm space-y-4">
|
||||
<div className="relative group">
|
||||
<InputFieldWithLabel
|
||||
label="New device name"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Plex Media Server"
|
||||
size="MD"
|
||||
autoFocus
|
||||
error={error?.message.toString()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="MD"
|
||||
theme="primary"
|
||||
type="submit"
|
||||
text="Rename Device"
|
||||
textAlign="center"
|
||||
/>
|
||||
</Form>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DeviceIdRename.loader = loader;
|
||||
DeviceIdRename.action = action;
|
||||
107
ui/src/routes/devices.$id.setup.tsx
Normal file
107
ui/src/routes/devices.$id.setup.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import SimpleNavbar from "@components/SimpleNavbar";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import StepCounter from "@components/StepCounter";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
redirect,
|
||||
useActionData,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { Button } from "@components/Button";
|
||||
import { checkAuth } from "@/main";
|
||||
import api from "../api";
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
await checkAuth();
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${params.id}`, {
|
||||
method: "GET",
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
} else {
|
||||
return redirect("/devices");
|
||||
}
|
||||
};
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
// Handle form submission
|
||||
const { name, id, returnTo } = Object.fromEntries(await request.formData());
|
||||
const res = await api.PUT(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, { name });
|
||||
|
||||
if (res.ok) {
|
||||
return redirect(returnTo?.toString() ?? `/devices/${id}`);
|
||||
} else {
|
||||
return { error: "There was an error creating your device" };
|
||||
}
|
||||
};
|
||||
|
||||
export default function SetupRoute() {
|
||||
const action = useActionData() as { error?: string };
|
||||
const { id } = useParams() as { id: string };
|
||||
const [sp] = useSearchParams();
|
||||
const returnTo = sp.get("returnTo");
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar />
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-2xl -mt-32 space-y-8">
|
||||
<div className="text-center">
|
||||
<StepCounter currStepIdx={1} nSteps={2} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Let's name your device</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Name your device so you can easily identify it later. You can change
|
||||
this name at any time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Fieldset className="space-y-12">
|
||||
<Form method="POST" className="max-w-sm mx-auto space-y-4">
|
||||
<InputFieldWithLabel
|
||||
label="Device Name"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Plex Media Server"
|
||||
autoFocus
|
||||
data-1p-ignore
|
||||
autoComplete="organization"
|
||||
error={action?.error?.toString()}
|
||||
/>
|
||||
|
||||
<input type="hidden" name="id" value={id} />
|
||||
{returnTo && <input type="hidden" name="redirect" value={returnTo} />}
|
||||
<Button
|
||||
size="LG"
|
||||
theme="primary"
|
||||
fullWidth
|
||||
type="submit"
|
||||
text="Finish Setup"
|
||||
textAlign="center"
|
||||
/>
|
||||
</Form>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
SetupRoute.loader = loader;
|
||||
SetupRoute.action = action;
|
||||
501
ui/src/routes/devices.$id.tsx
Normal file
501
ui/src/routes/devices.$id.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cx } from "@/cva.config";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import {
|
||||
HidState,
|
||||
UpdateState,
|
||||
useHidStore,
|
||||
User,
|
||||
useRTCStore,
|
||||
useUiStore,
|
||||
useUpdateStore,
|
||||
useVideoStore,
|
||||
useMountMediaStore,
|
||||
VideoState,
|
||||
} from "@/hooks/stores";
|
||||
import WebRTCVideo from "@components/WebRTCVideo";
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Params,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
import SettingsSidebar from "@/components/sidebar/settings";
|
||||
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
|
||||
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import UpdateDialog from "@components/UpdateDialog";
|
||||
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
|
||||
import api from "../api";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
import FocusTrap from "focus-trap-react";
|
||||
import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal";
|
||||
import TerminalWrapper from "../components/Terminal";
|
||||
|
||||
interface LocalLoaderResp {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
}
|
||||
|
||||
interface CloudLoaderResp {
|
||||
deviceName: string;
|
||||
user: User | null;
|
||||
iceConfig: {
|
||||
iceServers: { credential?: string; urls: string | string[]; username?: string };
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface LocalDevice {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
const deviceLoader = async () => {
|
||||
const res = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (!res.isSetup) return redirect("/welcome");
|
||||
|
||||
const deviceRes = await api.GET(`${import.meta.env.VITE_SIGNAL_API}/device`);
|
||||
if (deviceRes.status === 401) return redirect("/login-local");
|
||||
if (deviceRes.ok) {
|
||||
const device = (await deviceRes.json()) as LocalDevice;
|
||||
return { authMode: device.authMode };
|
||||
}
|
||||
|
||||
throw new Error("Error fetching device");
|
||||
};
|
||||
|
||||
const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> => {
|
||||
const user = await checkAuth();
|
||||
|
||||
const iceResp = await api.POST(`${import.meta.env.VITE_CLOUD_API}/webrtc/ice_config`);
|
||||
const iceConfig = await iceResp.json();
|
||||
|
||||
const deviceResp = await api.GET(
|
||||
`${import.meta.env.VITE_CLOUD_API}/devices/${params.id}`,
|
||||
);
|
||||
|
||||
if (!deviceResp.ok) {
|
||||
if (deviceResp.status === 404) {
|
||||
throw new Response("Device not found", { status: 404 });
|
||||
}
|
||||
|
||||
throw new Error("Error fetching device");
|
||||
}
|
||||
|
||||
const { device } = (await deviceResp.json()) as {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
};
|
||||
|
||||
return { user, iceConfig, deviceName: device.name || device.id };
|
||||
};
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params);
|
||||
};
|
||||
|
||||
export default function KvmIdRoute() {
|
||||
const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp;
|
||||
|
||||
// Depending on the mode, we set the appropriate variables
|
||||
const user = "user" in loaderResp ? loaderResp.user : null;
|
||||
const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null;
|
||||
const iceConfig = "iceConfig" in loaderResp ? loaderResp.iceConfig : null;
|
||||
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
|
||||
|
||||
const params = useParams() as { id: string };
|
||||
const sidebarView = useUiStore(state => state.sidebarView);
|
||||
const [queryParams, setQueryParams] = useSearchParams();
|
||||
|
||||
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
|
||||
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
|
||||
const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
|
||||
const setPeerConnection = useRTCStore(state => state.setPeerConnection);
|
||||
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
|
||||
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
|
||||
const setTransceiver = useRTCStore(state => state.setTransceiver);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
otaState,
|
||||
setOtaState,
|
||||
isUpdateDialogOpen,
|
||||
setIsUpdateDialogOpen,
|
||||
setModalView,
|
||||
} = useUpdateStore();
|
||||
|
||||
const [isOtherSessionConnectedModalOpen, setIsOtherSessionConnectedModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const sdp = useCallback(
|
||||
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
|
||||
if (!pc) return;
|
||||
if (event.candidate !== null) return;
|
||||
|
||||
try {
|
||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||
const res = await api.POST(`${import.meta.env.VITE_SIGNAL_API}/webrtc/session`, {
|
||||
sd,
|
||||
// When on device, we don't need to specify the device id, as it's already known
|
||||
...(isOnDevice ? {} : { id: params.id }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (isOnDevice) {
|
||||
if (res.status === 401) {
|
||||
return navigate("/login-local");
|
||||
}
|
||||
}
|
||||
|
||||
if (isInCloud) {
|
||||
// The cloud API returns a 401 if the user is not logged in
|
||||
// Most likely the session has expired
|
||||
if (res.status === 401) return navigate("/login");
|
||||
|
||||
// If can be a few things
|
||||
// - In cloud mode, the cloud api would return a 404, if the device hasn't contacted the cloud yet
|
||||
// - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit
|
||||
// Regardless, we should close the peer connection and let the useInterval handle reconnecting
|
||||
if (!res.ok) {
|
||||
pc?.close();
|
||||
console.error(`Error setting SDP - Status: ${res.status}}`, json);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pc.setRemoteDescription(
|
||||
new RTCSessionDescription(JSON.parse(atob(json.sd))),
|
||||
).catch(e => console.log(`Error setting remote description: ${e}`));
|
||||
} catch (error) {
|
||||
console.error(`Error setting SDP: ${error}`);
|
||||
pc?.close();
|
||||
}
|
||||
},
|
||||
[navigate, params.id],
|
||||
);
|
||||
|
||||
const connectWebRTC = useCallback(async () => {
|
||||
console.log("Attempting to connect WebRTC");
|
||||
const pc = new RTCPeerConnection({
|
||||
// We only use STUN or TURN servers if we're in the cloud
|
||||
...(isInCloud && iceConfig?.iceServers
|
||||
? { iceServers: [iceConfig?.iceServers] }
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Set up event listeners and data channels
|
||||
pc.onconnectionstatechange = () => {
|
||||
setPeerConnectionState(pc.connectionState);
|
||||
};
|
||||
|
||||
pc.onicecandidate = event => sdp(event, pc);
|
||||
|
||||
pc.ontrack = function (event) {
|
||||
setMediaMediaStream(event.streams[0]);
|
||||
};
|
||||
|
||||
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
|
||||
|
||||
const rpcDataChannel = pc.createDataChannel("rpc");
|
||||
rpcDataChannel.onopen = () => {
|
||||
setRpcDataChannel(rpcDataChannel);
|
||||
};
|
||||
|
||||
const diskDataChannel = pc.createDataChannel("disk");
|
||||
diskDataChannel.onopen = () => {
|
||||
setDiskChannel(diskDataChannel);
|
||||
};
|
||||
|
||||
try {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
setPeerConnection(pc);
|
||||
} catch (e) {
|
||||
console.error(`Error creating offer: ${e}`);
|
||||
}
|
||||
}, [
|
||||
iceConfig?.iceServers,
|
||||
sdp,
|
||||
setDiskChannel,
|
||||
setMediaMediaStream,
|
||||
setPeerConnection,
|
||||
setPeerConnectionState,
|
||||
setRpcDataChannel,
|
||||
setTransceiver,
|
||||
]);
|
||||
|
||||
// WebRTC connection management
|
||||
useInterval(() => {
|
||||
if (
|
||||
["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// We don't want to connect if another session is connected
|
||||
if (isOtherSessionConnectedModalOpen) return;
|
||||
connectWebRTC();
|
||||
}, 3000);
|
||||
|
||||
// On boot, if the connection state is undefined, we connect to the WebRTC
|
||||
useEffect(() => {
|
||||
if (peerConnection?.connectionState === undefined) {
|
||||
connectWebRTC();
|
||||
}
|
||||
}, [connectWebRTC, peerConnection?.connectionState]);
|
||||
|
||||
// Cleanup effect
|
||||
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
|
||||
const clearCandidatePairStats = useRTCStore(state => state.clearCandidatePairStats);
|
||||
const setSidebarView = useUiStore(state => state.setSidebarView);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
peerConnection?.close();
|
||||
};
|
||||
}, [peerConnection]);
|
||||
|
||||
// For some reason, we have to have this unmount separate from the cleanup effect above
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearInboundRtpStats();
|
||||
clearCandidatePairStats();
|
||||
setSidebarView(null);
|
||||
setPeerConnection(null);
|
||||
};
|
||||
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]);
|
||||
|
||||
// TURN server usage detection
|
||||
useEffect(() => {
|
||||
if (peerConnection?.connectionState !== "connected") return;
|
||||
const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState();
|
||||
|
||||
const lastLocalStat = Array.from(localCandidateStats).pop();
|
||||
if (!lastLocalStat?.length) return;
|
||||
const localCandidateIsUsingTurn = lastLocalStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
|
||||
|
||||
const lastRemoteStat = Array.from(remoteCandidateStats).pop();
|
||||
if (!lastRemoteStat?.length) return;
|
||||
const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
|
||||
|
||||
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
|
||||
}, [peerConnection?.connectionState, setIsTurnServerInUse]);
|
||||
|
||||
// TURN server usage reporting
|
||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
||||
const lastBytesReceived = useRef<number>(0);
|
||||
const lastBytesSent = useRef<number>(0);
|
||||
|
||||
useInterval(() => {
|
||||
// Don't report usage if we're not using the turn server
|
||||
if (!isTurnServerInUse) return;
|
||||
const { candidatePairStats } = useRTCStore.getState();
|
||||
|
||||
const lastCandidatePair = Array.from(candidatePairStats).pop();
|
||||
const report = lastCandidatePair?.[1];
|
||||
if (!report) return;
|
||||
|
||||
let bytesReceivedDelta = 0;
|
||||
let bytesSentDelta = 0;
|
||||
|
||||
if (report.bytesReceived) {
|
||||
bytesReceivedDelta = report.bytesReceived - lastBytesReceived.current;
|
||||
lastBytesReceived.current = report.bytesReceived;
|
||||
}
|
||||
|
||||
if (report.bytesSent) {
|
||||
bytesSentDelta = report.bytesSent - lastBytesSent.current;
|
||||
lastBytesSent.current = report.bytesSent;
|
||||
}
|
||||
|
||||
// Fire and forget
|
||||
api.POST(`${import.meta.env.VITE_CLOUD_API}/webrtc/turn_activity`, {
|
||||
bytesReceived: bytesReceivedDelta,
|
||||
bytesSent: bytesSentDelta,
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
const setUsbState = useHidStore(state => state.setUsbState);
|
||||
const setHdmiState = useVideoStore(state => state.setHdmiState);
|
||||
|
||||
const [hasUpdated, setHasUpdated] = useState(false);
|
||||
function onJsonRpcRequest(resp: JsonRpcRequest) {
|
||||
if (resp.method === "otherSessionConnected") {
|
||||
console.log("otherSessionConnected", resp.params);
|
||||
setIsOtherSessionConnectedModalOpen(true);
|
||||
}
|
||||
|
||||
if (resp.method === "usbState") {
|
||||
setUsbState(resp.params as unknown as HidState["usbState"]);
|
||||
}
|
||||
|
||||
if (resp.method === "videoInputState") {
|
||||
setHdmiState(resp.params as Parameters<VideoState["setHdmiState"]>[0]);
|
||||
}
|
||||
|
||||
if (resp.method === "otaState") {
|
||||
const otaState = resp.params as UpdateState["otaState"];
|
||||
setOtaState(otaState);
|
||||
|
||||
if (otaState.updating === true) {
|
||||
setHasUpdated(true);
|
||||
}
|
||||
|
||||
if (hasUpdated && otaState.updating === false) {
|
||||
setHasUpdated(false);
|
||||
|
||||
if (otaState.error) {
|
||||
setModalView("error");
|
||||
setIsUpdateDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.search = "";
|
||||
currentUrl.searchParams.set("updateSuccess", "true");
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
const [send] = useJsonRpc(onJsonRpcRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
send("getVideoState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
|
||||
});
|
||||
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
window.send = send;
|
||||
|
||||
// When the update is successful, we need to refresh the client javascript and show a success modal
|
||||
useEffect(() => {
|
||||
if (queryParams.get("updateSuccess")) {
|
||||
setModalView("updateCompleted");
|
||||
setIsUpdateDialogOpen(true);
|
||||
setQueryParams({});
|
||||
}
|
||||
}, [queryParams, setIsUpdateDialogOpen, setModalView, setQueryParams]);
|
||||
|
||||
const diskChannel = useRTCStore(state => state.diskChannel)!;
|
||||
const file = useMountMediaStore(state => state.localFile)!;
|
||||
useEffect(() => {
|
||||
if (!diskChannel || !file) return;
|
||||
diskChannel.onmessage = async e => {
|
||||
console.log("Received", e.data);
|
||||
const data = JSON.parse(e.data);
|
||||
const blob = file.slice(data.start, data.end);
|
||||
const buf = await blob.arrayBuffer();
|
||||
const header = new ArrayBuffer(16);
|
||||
const headerView = new DataView(header);
|
||||
headerView.setBigUint64(0, BigInt(data.start), false); // start offset, big-endian
|
||||
headerView.setBigUint64(8, BigInt(buf.byteLength), false); // length, big-endian
|
||||
const fullData = new Uint8Array(header.byteLength + buf.byteLength);
|
||||
fullData.set(new Uint8Array(header), 0);
|
||||
fullData.set(new Uint8Array(buf), header.byteLength);
|
||||
diskChannel.send(fullData);
|
||||
};
|
||||
}, [diskChannel, file]);
|
||||
|
||||
// System update
|
||||
const disableKeyboardFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
|
||||
return (
|
||||
<>
|
||||
<Transition show={!isUpdateDialogOpen && otaState.updating}>
|
||||
<div className="fixed inset-0 z-10 flex items-start justify-center w-full h-full max-w-xl mx-auto translate-y-8 pointer-events-none">
|
||||
<div className="transition duration-1000 ease-in data-[closed]:opacity-0">
|
||||
<UpdateInProgressStatusCard
|
||||
setIsUpdateDialogOpen={setIsUpdateDialogOpen}
|
||||
setModalView={setModalView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div className="relative h-full">
|
||||
<FocusTrap
|
||||
paused={disableKeyboardFocusTrap}
|
||||
focusTrapOptions={{
|
||||
allowOutsideClick: true,
|
||||
escapeDeactivates: false,
|
||||
fallbackFocus: "#videoFocusTrap",
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0">
|
||||
<button className="absolute top-0" tabIndex={-1} id="videoFocusTrap" />
|
||||
</div>
|
||||
</FocusTrap>
|
||||
<div className="grid h-full select-none grid-rows-headerBody">
|
||||
<DashboardNavbar
|
||||
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
|
||||
showConnectionStatus={true}
|
||||
isLoggedIn={authMode === "password" || !!user}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
kvmName={deviceName || "JetKVM Device"}
|
||||
/>
|
||||
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<WebRTCVideo />
|
||||
<SidebarContainer sidebarView={sidebarView} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateDialog open={isUpdateDialogOpen} setOpen={setIsUpdateDialogOpen} />
|
||||
<OtherSessionConnectedModal
|
||||
open={isOtherSessionConnectedModalOpen}
|
||||
setOpen={state => {
|
||||
if (state === false) {
|
||||
connectWebRTC();
|
||||
}
|
||||
|
||||
// It takes some time for the WebRTC connection to be established, so we wait a bit before closing the modal
|
||||
setTimeout(() => {
|
||||
setIsOtherSessionConnectedModalOpen(state);
|
||||
}, 1000);
|
||||
}}
|
||||
/>
|
||||
<TerminalWrapper />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContainer({ sidebarView }: { sidebarView: string | null }) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex shrink-0 border-l border-l-slate-800/20 transition-all duration-500 ease-in-out dark:border-l-slate-300/20",
|
||||
{ "border-x-transparent": !sidebarView },
|
||||
)}
|
||||
style={{ width: sidebarView ? "493px" : 0 }}
|
||||
>
|
||||
<div className="relative w-[493px] shrink-0">
|
||||
<Transition show={sidebarView === "system"} unmount={false}>
|
||||
<div className="absolute inset-0">
|
||||
<SettingsSidebar />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition show={sidebarView === "connection-stats"} unmount={false}>
|
||||
<div className="absolute inset-0">
|
||||
<ConnectionStatsSidebar />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
KvmIdRoute.loader = loader;
|
||||
43
ui/src/routes/devices.already-adopted.tsx
Normal file
43
ui/src/routes/devices.already-adopted.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import SimpleNavbar from "@/components/SimpleNavbar";
|
||||
import Container from "@/components/Container";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
|
||||
export default function DevicesAlreadyAdopted() {
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar />
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-2xl -mt-16 space-y-8">
|
||||
<div className="space-y-4 text-center">
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Device Already Registered</h1>
|
||||
<p className="text-lg text-slate-600 dark:text-slate-400">
|
||||
This device is currently registered to another user in our cloud
|
||||
dashboard.
|
||||
</p>
|
||||
<p className="mt-4 text-lg text-slate-600 dark:text-slate-400">
|
||||
If you're the new owner, please ask the previous owner to de-register
|
||||
the device from their account in the cloud dashboard. If you believe
|
||||
this is an error, contact our support team for assistance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<LinkButton
|
||||
to="/devices"
|
||||
size="LG"
|
||||
theme="primary"
|
||||
text="Return to Dashboard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
ui/src/routes/devices.tsx
Normal file
102
ui/src/routes/devices.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useLoaderData, useRevalidator } from "react-router-dom";
|
||||
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import KvmCard from "@components/KvmCard";
|
||||
import useInterval from "@/hooks/useInterval";
|
||||
import { checkAuth } from "@/main";
|
||||
import { User } from "@/hooks/stores";
|
||||
import EmptyCard from "@components/EmptyCard";
|
||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||
import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||
|
||||
interface LoaderData {
|
||||
devices: { id: string; name: string; online: boolean; lastSeen: string }[];
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const loader = async () => {
|
||||
const user = await checkAuth();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
const { devices } = await res.json();
|
||||
return { devices, user };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { devices: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export default function DevicesRoute() {
|
||||
const { devices, user } = useLoaderData() as LoaderData;
|
||||
const revalidate = useRevalidator();
|
||||
useInterval(revalidate.revalidate, 4000);
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div className="grid h-full select-none grid-rows-headerBody">
|
||||
<DashboardNavbar
|
||||
isLoggedIn={!!user}
|
||||
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
/>
|
||||
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
|
||||
<div className="flex items-center justify-between pb-4 mt-8 border-b border-b-slate-800/20 dark:border-b-slate-300/20">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-black dark:text-white">
|
||||
Cloud KVMs
|
||||
</h1>
|
||||
<p className="text-base text-slate-700 dark:text-slate-400">
|
||||
Manage your cloud KVMs and connect to them securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{devices.length === 0 ? (
|
||||
<div className="max-w-3xl">
|
||||
<EmptyCard
|
||||
IconElm={LuMonitorSmartphone}
|
||||
headline="No devices found"
|
||||
description="You don't have any devices with enabled JetKVM Cloud yet."
|
||||
BtnElm={
|
||||
<LinkButton
|
||||
to="https://jetkvm.com/docs/networking/remote-access"
|
||||
size="SM"
|
||||
theme="primary"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
text="Learn more"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{devices.map(x => {
|
||||
return (
|
||||
<KvmCard
|
||||
key={x.id}
|
||||
id={x.id}
|
||||
title={x.name ?? x.id}
|
||||
lastSeen={x.lastSeen ? new Date(x.lastSeen) : null}
|
||||
online={x.online}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
ui/src/routes/login-local.tsx
Normal file
132
ui/src/routes/login-local.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import SimpleNavbar from "@components/SimpleNavbar";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { Button } from "@components/Button";
|
||||
import { useState } from "react";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import api from "../api";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
import ExtLink from "../components/ExtLink";
|
||||
|
||||
const loader = async () => {
|
||||
const res = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (!res.isSetup) return redirect("/welcome");
|
||||
|
||||
const deviceRes = await api.GET(`${import.meta.env.VITE_SIGNAL_API}/device`);
|
||||
if (deviceRes.ok) return redirect("/");
|
||||
return null;
|
||||
};
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const password = formData.get("password");
|
||||
|
||||
try {
|
||||
const response = await api.POST(
|
||||
`${import.meta.env.VITE_SIGNAL_API}/auth/login-local`,
|
||||
{
|
||||
password,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return redirect("/");
|
||||
} else {
|
||||
return { error: "Invalid password" };
|
||||
}
|
||||
} catch (error) {
|
||||
return { error: "An error occurred while logging in" };
|
||||
}
|
||||
};
|
||||
|
||||
export default function LoginLocalRoute() {
|
||||
const actionData = useActionData() as { error?: string; success?: boolean };
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen grid-rows-layout">
|
||||
<SimpleNavbar />
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-2xl -mt-32 space-y-8">
|
||||
<div className="flex items-center justify-center">
|
||||
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Welcome back to JetKVM</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Enter your password to access your JetKVM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Fieldset className="space-y-12">
|
||||
<Form method="POST" className="max-w-sm mx-auto space-y-4">
|
||||
<div className="space-y-4">
|
||||
<InputFieldWithLabel
|
||||
label="Password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
autoFocus
|
||||
error={actionData?.error}
|
||||
TrailingElm={
|
||||
showPassword ? (
|
||||
<div
|
||||
onClick={() => setShowPassword(false)}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setShowPassword(true)}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="LG"
|
||||
theme="primary"
|
||||
fullWidth
|
||||
type="submit"
|
||||
text="Log In"
|
||||
textAlign="center"
|
||||
/>
|
||||
|
||||
<div className="flex justify-start mt-4 text-xs text-slate-500 dark:text-slate-400">
|
||||
<ExtLink
|
||||
href="https://jetkvm.com/docs/networking/local-access#reset-password"
|
||||
className="hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</ExtLink>
|
||||
</div>
|
||||
</Form>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LoginLocalRoute.loader = loader;
|
||||
LoginLocalRoute.action = action;
|
||||
33
ui/src/routes/login.tsx
Normal file
33
ui/src/routes/login.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import AuthLayout from "@components/AuthLayout";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
|
||||
export default function LoginRoute() {
|
||||
const [sq] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const deviceId = sq.get("deviceId") || location.state?.deviceId;
|
||||
|
||||
if (deviceId) {
|
||||
return (
|
||||
<AuthLayout
|
||||
showCounter={true}
|
||||
title="Connect your JetKVM to the cloud"
|
||||
description="Unlock remote access and advanced features for your device"
|
||||
action="Log in & Connect device"
|
||||
// Header CTA
|
||||
cta="Don't have an account?"
|
||||
ctaHref={`/signup?${sq.toString()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Log in to your JetKVM account"
|
||||
description="Log in to access and manage your devices securely"
|
||||
action="Log in"
|
||||
// Header CTA
|
||||
cta="New to JetKVM?"
|
||||
ctaHref={`/signup?${sq.toString()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
ui/src/routes/signup.tsx
Normal file
32
ui/src/routes/signup.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import AuthLayout from "@components/AuthLayout";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
|
||||
export default function SignupRoute() {
|
||||
const [sq] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const deviceId = sq.get("deviceId") || location.state?.deviceId;
|
||||
|
||||
if (deviceId) {
|
||||
return (
|
||||
<AuthLayout
|
||||
showCounter={true}
|
||||
title="Connect your JetKVM to the cloud"
|
||||
description="Unlock remote access and advanced features for your device."
|
||||
action="Signup & Connect device"
|
||||
cta="Already have an account?"
|
||||
ctaHref={`/login?${sq.toString()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Create your JetKVM account"
|
||||
description="Create your account and start managing your devices with ease."
|
||||
action="Create Account"
|
||||
// Header CTA
|
||||
cta="Already have an account?"
|
||||
ctaHref={`/login?${sq.toString()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
156
ui/src/routes/welcome-local.mode.tsx
Normal file
156
ui/src/routes/welcome-local.mode.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
||||
import { Button } from "@components/Button";
|
||||
import { useState } from "react";
|
||||
import { GridCard } from "../components/Card";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import { cx } from "../cva.config";
|
||||
import api from "../api";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
|
||||
const loader = async () => {
|
||||
const res = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (res.isSetup) return redirect("/login-local");
|
||||
return null;
|
||||
};
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const localAuthMode = formData.get("localAuthMode");
|
||||
if (!localAuthMode) return { error: "Please select an authentication mode" };
|
||||
|
||||
if (localAuthMode === "password") {
|
||||
return redirect("/welcome/password");
|
||||
}
|
||||
|
||||
if (localAuthMode === "noPassword") {
|
||||
try {
|
||||
await api.POST(`${import.meta.env.VITE_SIGNAL_API}/device/setup`, {
|
||||
localAuthMode,
|
||||
});
|
||||
return redirect("/");
|
||||
} catch (error) {
|
||||
console.error("Error setting authentication mode:", error);
|
||||
return { error: "An error occurred while setting the authentication mode" };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "Invalid authentication mode" };
|
||||
};
|
||||
|
||||
export default function WelcomeLocalModeRoute() {
|
||||
const actionData = useActionData() as { error?: string };
|
||||
const [selectedMode, setSelectedMode] = useState<"password" | "noPassword" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen">
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-xl space-y-8">
|
||||
<div className="flex items-center justify-center opacity-0 animate-fadeIn">
|
||||
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="space-y-2 text-center opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Local Authentication Method</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Select how you{"'"}d like to secure your JetKVM device locally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form method="POST" className="space-y-8">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-6 opacity-0 animate-fadeIn sm:grid-cols-2"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
{["password", "noPassword"].map(mode => (
|
||||
<GridCard
|
||||
key={mode}
|
||||
cardClassName={cx("transition-all duration-100", {
|
||||
"!outline-blue-700 !outline-2": selectedMode === mode,
|
||||
"hover:!outline-blue-700": selectedMode !== mode,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="relative flex flex-col items-center p-6 cursor-pointer select-none"
|
||||
onClick={() => setSelectedMode(mode as "password" | "noPassword")}
|
||||
>
|
||||
<div className="space-y-0 text-center">
|
||||
<h3 className="text-base font-bold text-black dark:text-white">
|
||||
{mode === "password" ? "Password protected" : "No Password"}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-center text-gray-600 dark:text-gray-400">
|
||||
{mode === "password"
|
||||
? "Secure your device with a password for added protection."
|
||||
: "Quick access without password authentication."}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="radio"
|
||||
name="localAuthMode"
|
||||
value={mode}
|
||||
checked={selectedMode === mode}
|
||||
onChange={() => {
|
||||
setSelectedMode(mode as "password" | "noPassword");
|
||||
}}
|
||||
className="absolute w-4 h-4 text-blue-600 right-2 top-2"
|
||||
/>
|
||||
</div>
|
||||
</GridCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<p
|
||||
className="text-sm text-center text-red-600 opacity-0 dark:text-red-400 animate-fadeIn"
|
||||
style={{ animationDelay: "500ms" }}
|
||||
>
|
||||
{actionData.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="max-w-sm mx-auto opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "500ms" }}
|
||||
>
|
||||
<Button
|
||||
size="LG"
|
||||
theme="primary"
|
||||
fullWidth
|
||||
type="submit"
|
||||
text="Continue"
|
||||
textAlign="center"
|
||||
disabled={!selectedMode}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<p
|
||||
className="max-w-md mx-auto text-xs text-center opacity-0 animate-fadeIn text-slate-500 dark:text-slate-400"
|
||||
style={{ animationDelay: "600ms" }}
|
||||
>
|
||||
You can always change your authentication method later in the settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
WelcomeLocalModeRoute.action = action;
|
||||
WelcomeLocalModeRoute.loader = loader;
|
||||
168
ui/src/routes/welcome-local.password.tsx
Normal file
168
ui/src/routes/welcome-local.password.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { Button } from "@components/Button";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import api from "../api";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
|
||||
const loader = async () => {
|
||||
const res = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (res.isSetup) return redirect("/login-local");
|
||||
return null;
|
||||
};
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const password = formData.get("password");
|
||||
const confirmPassword = formData.get("confirmPassword");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return { error: "Passwords do not match" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.POST(`${import.meta.env.VITE_SIGNAL_API}/device/setup`, {
|
||||
localAuthMode: "password",
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return redirect("/");
|
||||
} else {
|
||||
return { error: "Failed to set password" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error setting password:", error);
|
||||
return { error: "An error occurred while setting the password" };
|
||||
}
|
||||
};
|
||||
|
||||
export default function WelcomeLocalPasswordRoute() {
|
||||
const actionData = useActionData() as { error?: string };
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Don't focus immediately, let the animation finish
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}, 1000); // 1 second delay
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen">
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<div className="flex items-center justify-center opacity-0 animate-fadeIn">
|
||||
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="space-y-2 text-center opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Set a Password</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Create a strong password to secure your JetKVM device locally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Fieldset className="space-y-12">
|
||||
<Form method="POST" className="max-w-sm mx-auto space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<InputFieldWithLabel
|
||||
label="Password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
placeholder="Enter a password"
|
||||
autoComplete="new-password"
|
||||
ref={passwordInputRef}
|
||||
TrailingElm={
|
||||
showPassword ? (
|
||||
<div
|
||||
onClick={() => setShowPassword(false)}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setShowPassword(true)}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<InputFieldWithLabel
|
||||
label="Confirm Password"
|
||||
autoComplete="new-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
error={actionData?.error}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionData?.error && <p className="text-sm text-red-600">{}</p>}
|
||||
|
||||
<div
|
||||
className="opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "600ms" }}
|
||||
>
|
||||
<Button
|
||||
size="LG"
|
||||
theme="primary"
|
||||
fullWidth
|
||||
type="submit"
|
||||
text="Set Password"
|
||||
textAlign="center"
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</Fieldset>
|
||||
|
||||
<p
|
||||
className="max-w-md text-xs text-center opacity-0 animate-fadeIn text-slate-500 dark:text-slate-400"
|
||||
style={{ animationDelay: "800ms" }}
|
||||
>
|
||||
This password will be used to secure your device data and protect against
|
||||
unauthorized access.{" "}
|
||||
<span className="font-bold">All data remains on your local device.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
WelcomeLocalPasswordRoute.action = action;
|
||||
WelcomeLocalPasswordRoute.loader = loader;
|
||||
104
ui/src/routes/welcome-local.tsx
Normal file
104
ui/src/routes/welcome-local.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import DeviceImage from "@/assets/jetkvm-device-still.png";
|
||||
import LogoMark from "@/assets/logo-mark.png";
|
||||
import { cx } from "cva";
|
||||
import api from "../api";
|
||||
import { redirect } from "react-router-dom";
|
||||
|
||||
export interface DeviceStatus {
|
||||
isSetup: boolean;
|
||||
}
|
||||
|
||||
const loader = async () => {
|
||||
const res = await api
|
||||
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (res.isSetup) return redirect("/login-local");
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.src = DeviceImage;
|
||||
img.onload = () => setImageLoaded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen">
|
||||
{imageLoaded && (
|
||||
<Container>
|
||||
<div className="flex items-center justify-center w-full h-full isolate">
|
||||
<div className="max-w-3xl text-center">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center opacity-0 animate-fadeIn animation-delay-1000">
|
||||
<img src={LogoWhiteIcon} alt="JetKVM Logo" className="h-[32px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="JetKVM Logo" className="h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="space-y-1 opacity-0 animate-fadeIn"
|
||||
style={{ animationDelay: "1500ms" }}
|
||||
>
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">
|
||||
Welcome to JetKVM
|
||||
</h1>
|
||||
<p className="text-lg font-medium text-slate-600 dark:text-slate-400">
|
||||
Control any computer remotely
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="!-mt-2 -ml-6 flex items-center justify-center">
|
||||
<img
|
||||
src={DeviceImage}
|
||||
alt="JetKVM Device"
|
||||
className="animation-delay-0 max-w-md scale-[0.98] animate-fadeInScaleFloat opacity-0 transition-all duration-1000 ease-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-8 space-y-4">
|
||||
<p
|
||||
style={{ animationDelay: "2000ms" }}
|
||||
className="max-w-lg mx-auto text-lg opacity-0 animate-fadeIn text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
JetKVM combines powerful hardware with intuitive software to provide a
|
||||
seamless remote control experience.
|
||||
</p>
|
||||
<div
|
||||
style={{ animationDelay: "2300ms" }}
|
||||
className="opacity-0 animate-fadeIn"
|
||||
>
|
||||
<LinkButton
|
||||
size="LG"
|
||||
theme="light"
|
||||
text="Set up your JetKVM"
|
||||
LeadingIcon={({ className }) => (
|
||||
<img src={LogoMark} className={cx(className, "mr-1.5 !h-5")} />
|
||||
)}
|
||||
textAlign="center"
|
||||
to="/welcome/mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
WelcomeRoute.loader = loader;
|
||||
Reference in New Issue
Block a user