feat: edit ssh key
This commit is contained in:
32
src/api.ts
32
src/api.ts
@ -103,11 +103,15 @@ export interface VmPayment {
|
||||
is_paid: boolean;
|
||||
}
|
||||
|
||||
export interface PathVm {
|
||||
ssh_key_id?: number;
|
||||
}
|
||||
|
||||
export class LNVpsApi {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher | undefined,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
async listVms() {
|
||||
const { data } = await this.#handleResponse<ApiResponse<Array<VmInstance>>>(
|
||||
@ -123,6 +127,13 @@ export class LNVpsApi {
|
||||
return data;
|
||||
}
|
||||
|
||||
async patchVm(id: number, req: PathVm) {
|
||||
const { data } = await this.#handleResponse<ApiResponse<void>>(
|
||||
await this.#req(`/api/v1/vm/${id}`, "PATCH", req),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async startVm(id: number) {
|
||||
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
|
||||
await this.#req(`/api/v1/vm/${id}/start`, "PATCH"),
|
||||
@ -196,20 +207,21 @@ export class LNVpsApi {
|
||||
async connect_terminal(id: number) {
|
||||
const u = `${this.url}/api/v1/console/${id}`;
|
||||
const auth = await this.#auth_event(u, "GET");
|
||||
const ws = new WebSocket(`${u}?auth=${base64.encode(
|
||||
new TextEncoder().encode(JSON.stringify(auth)),
|
||||
)}`);
|
||||
const ws = new WebSocket(
|
||||
`${u}?auth=${base64.encode(
|
||||
new TextEncoder().encode(JSON.stringify(auth)),
|
||||
)}`,
|
||||
);
|
||||
return await new Promise<WebSocket>((resolve, reject) => {
|
||||
ws.onopen = () => {
|
||||
resolve(ws);
|
||||
}
|
||||
};
|
||||
ws.onerror = (e) => {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
reject(e);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async #handleResponse<T extends ApiResponseBase>(rsp: Response) {
|
||||
if (rsp.ok) {
|
||||
return (await rsp.json()) as T;
|
||||
@ -230,7 +242,7 @@ export class LNVpsApi {
|
||||
.kind(EventKind.HttpAuthentication)
|
||||
.tag(["u", url])
|
||||
.tag(["method", method]);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async #auth(url: string, method: string) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import classNames from "classnames";
|
||||
import { MouseEventHandler } from "react";
|
||||
|
||||
type Props = {
|
||||
@ -15,7 +16,7 @@ export function Icon(props: Props) {
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={props.className}
|
||||
className={classNames(props.className, "cursor-pointer")}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<use href={href} />
|
||||
|
@ -1,32 +1,24 @@
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { useContext } from "react";
|
||||
import { AsyncButton } from "./button";
|
||||
import { loginNip7 } from "../login";
|
||||
import useLogin from "../hooks/login";
|
||||
import Profile from "./profile";
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
export default function LoginButton() {
|
||||
const system = useContext(SnortContext);
|
||||
const login = useLogin();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return !login ? (
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
if (window.nostr) {
|
||||
await loginNip7(system);
|
||||
} else {
|
||||
navigate("/new-account");
|
||||
}
|
||||
navigate("/login");
|
||||
}}
|
||||
>
|
||||
Sign In
|
||||
</AsyncButton>
|
||||
) : (
|
||||
<Link to="/account">
|
||||
<Profile link={NostrLink.publicKey(login.pubkey)} />
|
||||
<Profile link={NostrLink.publicKey(login.publicKey)} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ export default function Modal(props: ModalProps) {
|
||||
className={
|
||||
props.bodyClassName ??
|
||||
classNames(
|
||||
"relative bg-layer-1 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto max-lg:w-full",
|
||||
"relative bg-neutral-700 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto max-lg:w-full",
|
||||
{
|
||||
"max-xl:-translate-y-[calc(100vh-100dvh)]": props.ready ?? true,
|
||||
"max-xl:translate-y-[50vh]": !(props.ready ?? true),
|
||||
|
104
src/components/ssh-keys.tsx
Normal file
104
src/components/ssh-keys.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { LNVpsApi, UserSshKey } from "../api";
|
||||
import useLogin from "../hooks/login";
|
||||
import { ApiUrl } from "../const";
|
||||
import { AsyncButton } from "./button";
|
||||
|
||||
export default function SSHKeySelector({
|
||||
selectedKey,
|
||||
setSelectedKey,
|
||||
}: {
|
||||
selectedKey: UserSshKey["id"];
|
||||
setSelectedKey: (k: UserSshKey["id"]) => void;
|
||||
}) {
|
||||
const login = useLogin();
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newKeyError, setNewKeyError] = useState("");
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [showAddKey, setShowAddKey] = useState(false);
|
||||
const [sshKeys, setSshKeys] = useState<Array<UserSshKey>>([]);
|
||||
|
||||
const api = useMemo(() => {
|
||||
if (!login?.builder) return;
|
||||
const api = new LNVpsApi(ApiUrl, login.builder);
|
||||
return api;
|
||||
}, [login]);
|
||||
|
||||
async function addNewKey() {
|
||||
if (!api) return;
|
||||
setNewKeyError("");
|
||||
|
||||
try {
|
||||
const nk = await api.addSshKey(newKeyName, newKey);
|
||||
setNewKey("");
|
||||
setNewKeyName("");
|
||||
setSelectedKey(nk.id);
|
||||
setShowAddKey(false);
|
||||
api.listSshKeys().then((a) => setSshKeys(a));
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setNewKeyError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
api.listSshKeys().then((a) => {
|
||||
setSshKeys(a);
|
||||
if (a.length > 0) {
|
||||
setSelectedKey(a[0].id);
|
||||
} else {
|
||||
setShowAddKey(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sshKeys.length > 0 && (
|
||||
<>
|
||||
<b>Select SSH Key:</b>
|
||||
<select
|
||||
className="bg-neutral-900 p-2 rounded-xl"
|
||||
value={selectedKey}
|
||||
onChange={(e) => setSelectedKey(Number(e.target.value))}
|
||||
>
|
||||
{sshKeys.map((a) => (
|
||||
<option value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{!showAddKey && sshKeys.length > 0 && (
|
||||
<AsyncButton onClick={() => setShowAddKey(true)}>
|
||||
Add new SSH key
|
||||
</AsyncButton>
|
||||
)}
|
||||
{(showAddKey || sshKeys.length === 0) && (
|
||||
<>
|
||||
<b>Add SSH Key:</b>
|
||||
<textarea
|
||||
rows={5}
|
||||
placeholder="ssh-[rsa|ed25519] AA== id"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Key name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
/>
|
||||
<AsyncButton
|
||||
disabled={newKey.length < 10 || newKeyName.length < 2}
|
||||
onClick={addNewKey}
|
||||
>
|
||||
Add Key
|
||||
</AsyncButton>
|
||||
{newKeyError && <b className="text-red-500">{newKeyError}</b>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,9 +1,19 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { Login } from "../login";
|
||||
import { useContext, useSyncExternalStore } from "react";
|
||||
import { LoginState } from "../login";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
|
||||
export default function useLogin() {
|
||||
return useSyncExternalStore(
|
||||
(c) => Login.hook(c),
|
||||
() => Login.snapshot(),
|
||||
const session = useSyncExternalStore(
|
||||
(c) => LoginState.hook(c),
|
||||
() => LoginState.snapshot(),
|
||||
);
|
||||
const system = useContext(SnortContext);
|
||||
return session
|
||||
? {
|
||||
type: session.type,
|
||||
publicKey: session.publicKey,
|
||||
builder: LoginState.getSigner(),
|
||||
system,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
137
src/login.ts
137
src/login.ts
@ -1,53 +1,112 @@
|
||||
import { ExternalStore } from "@snort/shared";
|
||||
import {
|
||||
EventSigner,
|
||||
EventPublisher,
|
||||
Nip46Signer,
|
||||
Nip7Signer,
|
||||
PrivateKeySigner,
|
||||
SystemInterface,
|
||||
UserState,
|
||||
} from "@snort/system";
|
||||
|
||||
class LoginShell extends ExternalStore<UserState<void> | undefined> {
|
||||
#state?: UserState<void>;
|
||||
export interface LoginSession {
|
||||
type: "nip7" | "nsec" | "nip46";
|
||||
publicKey: string;
|
||||
privateKey?: string;
|
||||
bunker?: string;
|
||||
}
|
||||
class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||
#session?: LoginSession;
|
||||
#signer?: EventPublisher;
|
||||
|
||||
async login(signer: EventSigner, system: SystemInterface) {
|
||||
if (this.#state !== undefined) {
|
||||
throw new Error("Already logged in");
|
||||
constructor() {
|
||||
super();
|
||||
const s = window.localStorage.getItem("session");
|
||||
if (s) {
|
||||
this.#session = JSON.parse(s);
|
||||
// patch session
|
||||
if (this.#session) {
|
||||
this.#session.type ??= "nip7";
|
||||
}
|
||||
}
|
||||
const pubkey = await signer.getPubKey();
|
||||
this.#state = new UserState<void>(pubkey);
|
||||
await this.#state.init(signer, system);
|
||||
this.#state.on("change", () => this.notifyChange());
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
takeSnapshot() {
|
||||
return this.#state;
|
||||
return this.#session ? { ...this.#session } : undefined;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.#session = undefined;
|
||||
this.#signer = undefined;
|
||||
this.#save();
|
||||
}
|
||||
|
||||
login(pubkey: string, type: LoginSession["type"] = "nip7") {
|
||||
this.#session = {
|
||||
type: type ?? "nip7",
|
||||
publicKey: pubkey,
|
||||
};
|
||||
this.#save();
|
||||
}
|
||||
|
||||
loginPrivateKey(key: string) {
|
||||
const s = new PrivateKeySigner(key);
|
||||
this.#session = {
|
||||
type: "nsec",
|
||||
publicKey: s.getPubKey(),
|
||||
privateKey: key,
|
||||
};
|
||||
this.#save();
|
||||
}
|
||||
|
||||
loginBunker(url: string, localKey: string, remotePubkey: string) {
|
||||
this.#session = {
|
||||
type: "nip46",
|
||||
publicKey: remotePubkey,
|
||||
privateKey: localKey,
|
||||
bunker: url,
|
||||
};
|
||||
this.#save();
|
||||
}
|
||||
|
||||
getSigner() {
|
||||
if (!this.#signer && this.#session) {
|
||||
switch (this.#session.type) {
|
||||
case "nsec":
|
||||
this.#signer = new EventPublisher(
|
||||
new PrivateKeySigner(this.#session.privateKey!),
|
||||
this.#session.publicKey,
|
||||
);
|
||||
break;
|
||||
case "nip46":
|
||||
this.#signer = new EventPublisher(
|
||||
new Nip46Signer(
|
||||
this.#session.bunker!,
|
||||
new PrivateKeySigner(this.#session.privateKey!),
|
||||
),
|
||||
this.#session.publicKey,
|
||||
);
|
||||
break;
|
||||
case "nip7":
|
||||
this.#signer = new EventPublisher(
|
||||
new Nip7Signer(),
|
||||
this.#session.publicKey,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#signer) {
|
||||
return this.#signer;
|
||||
}
|
||||
throw "Signer not setup!";
|
||||
}
|
||||
|
||||
#save() {
|
||||
if (this.#session) {
|
||||
window.localStorage.setItem("session", JSON.stringify(this.#session));
|
||||
} else {
|
||||
window.localStorage.removeItem("session");
|
||||
}
|
||||
this.notifyChange();
|
||||
}
|
||||
}
|
||||
|
||||
export const Login = new LoginShell();
|
||||
|
||||
export async function loginNip7(system: SystemInterface) {
|
||||
const signer = new Nip7Signer();
|
||||
const pubkey = await signer.getPubKey();
|
||||
if (pubkey) {
|
||||
await Login.login(signer, system);
|
||||
} else {
|
||||
throw new Error("No nostr extension found");
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginPrivateKey(
|
||||
system: SystemInterface,
|
||||
key: string | Uint8Array | PrivateKeySigner,
|
||||
) {
|
||||
const signer =
|
||||
key instanceof PrivateKeySigner ? key : new PrivateKeySigner(key);
|
||||
const pubkey = signer.getPubKey();
|
||||
if (pubkey) {
|
||||
await Login.login(signer, system);
|
||||
} else {
|
||||
throw new Error("No nostr extension found");
|
||||
}
|
||||
}
|
||||
export const LoginState = new LoginStore();
|
||||
|
@ -31,7 +31,7 @@ const router = createBrowserRouter([
|
||||
element: <HomePage />,
|
||||
},
|
||||
{
|
||||
path: "/new-account",
|
||||
path: "/login",
|
||||
element: <SignUpPage />,
|
||||
},
|
||||
{
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LNVpsApi, VmInstance } from "../api";
|
||||
import useLogin from "../hooks/login";
|
||||
import { EventPublisher } from "@snort/system";
|
||||
import { ApiUrl } from "../const";
|
||||
import VpsInstanceRow from "../components/vps-instance";
|
||||
|
||||
@ -10,11 +9,8 @@ export default function AccountPage() {
|
||||
const [vms, setVms] = useState<Array<VmInstance>>([]);
|
||||
|
||||
async function loadVms() {
|
||||
if (!login?.signer) return;
|
||||
const api = new LNVpsApi(
|
||||
ApiUrl,
|
||||
new EventPublisher(login.signer, login.pubkey),
|
||||
);
|
||||
if (!login?.builder) return;
|
||||
const api = new LNVpsApi(ApiUrl, login.builder);
|
||||
const vms = await api.listVms();
|
||||
setVms(vms);
|
||||
}
|
||||
|
@ -1,75 +1,34 @@
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { LNVpsApi, UserSshKey, VmOsImage, VmTemplate } from "../api";
|
||||
import { LNVpsApi, VmOsImage, VmTemplate } from "../api";
|
||||
import { useEffect, useState } from "react";
|
||||
import CostLabel from "../components/cost";
|
||||
import { ApiUrl } from "../const";
|
||||
import { EventPublisher } from "@snort/system";
|
||||
import useLogin from "../hooks/login";
|
||||
import { AsyncButton } from "../components/button";
|
||||
import classNames from "classnames";
|
||||
import VpsResources from "../components/vps-resources";
|
||||
import OsImageName from "../components/os-image-name";
|
||||
import SSHKeySelector from "../components/ssh-keys";
|
||||
|
||||
export default function OrderPage() {
|
||||
const { state } = useLocation();
|
||||
const login = useLogin();
|
||||
const navigate = useNavigate();
|
||||
const template = state as VmTemplate | undefined;
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newKeyError, setNewKeyError] = useState("");
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [useImage, setUseImage] = useState(-1);
|
||||
const [useSshKey, setUseSshKey] = useState(-1);
|
||||
const [showAddKey, setShowAddKey] = useState(false);
|
||||
const [images, setImages] = useState<Array<VmOsImage>>([]);
|
||||
const [sshKeys, setSshKeys] = useState<Array<UserSshKey>>([]);
|
||||
const [orderError, setOrderError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!login?.signer) return;
|
||||
const api = new LNVpsApi(
|
||||
ApiUrl,
|
||||
new EventPublisher(login.signer, login.pubkey),
|
||||
);
|
||||
if (!login?.builder) return;
|
||||
const api = new LNVpsApi(ApiUrl, login.builder);
|
||||
api.listOsImages().then((a) => setImages(a));
|
||||
api.listSshKeys().then((a) => {
|
||||
setSshKeys(a);
|
||||
if (a.length > 0) {
|
||||
setUseSshKey(a[0].id);
|
||||
} else {
|
||||
setShowAddKey(true);
|
||||
}
|
||||
});
|
||||
}, [login]);
|
||||
|
||||
async function addNewKey() {
|
||||
if (!login?.signer) return;
|
||||
setNewKeyError("");
|
||||
const api = new LNVpsApi(
|
||||
ApiUrl,
|
||||
new EventPublisher(login.signer, login.pubkey),
|
||||
);
|
||||
|
||||
try {
|
||||
const nk = await api.addSshKey(newKeyName, newKey);
|
||||
setNewKey("");
|
||||
setNewKeyName("");
|
||||
setUseSshKey(nk.id);
|
||||
setShowAddKey(false);
|
||||
api.listSshKeys().then((a) => setSshKeys(a));
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setNewKeyError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrder() {
|
||||
if (!login?.signer || !template) return;
|
||||
const api = new LNVpsApi(
|
||||
ApiUrl,
|
||||
new EventPublisher(login.signer, login.pubkey),
|
||||
);
|
||||
if (!login?.builder || !template) return;
|
||||
const api = new LNVpsApi(ApiUrl, login.builder);
|
||||
|
||||
setOrderError("");
|
||||
try {
|
||||
@ -122,51 +81,7 @@ export default function OrderPage() {
|
||||
))}
|
||||
</div>
|
||||
<hr />
|
||||
<div className="flex flex-col gap-2">
|
||||
{sshKeys.length > 0 && (
|
||||
<>
|
||||
<b>Select SSH Key:</b>
|
||||
<select
|
||||
className="bg-neutral-900 p-2 rounded-xl"
|
||||
value={useSshKey}
|
||||
onChange={(e) => setUseSshKey(Number(e.target.value))}
|
||||
>
|
||||
{sshKeys.map((a) => (
|
||||
<option value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{!showAddKey && sshKeys.length > 0 && (
|
||||
<AsyncButton onClick={() => setShowAddKey(true)}>
|
||||
Add new SSH key
|
||||
</AsyncButton>
|
||||
)}
|
||||
{(showAddKey || sshKeys.length === 0) && (
|
||||
<>
|
||||
<b>Add SSH Key:</b>
|
||||
<textarea
|
||||
rows={5}
|
||||
placeholder="ssh-[rsa|ed25519] AA== id"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Key name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
/>
|
||||
<AsyncButton
|
||||
disabled={newKey.length < 10 || newKeyName.length < 2}
|
||||
onClick={addNewKey}
|
||||
>
|
||||
Add Key
|
||||
</AsyncButton>
|
||||
{newKeyError && <b className="text-red-500">{newKeyError}</b>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SSHKeySelector selectedKey={useSshKey} setSelectedKey={setUseSshKey} />
|
||||
<AsyncButton
|
||||
disabled={useSshKey === -1 || useImage === -1}
|
||||
onClick={createOrder}
|
||||
|
@ -1,12 +1,17 @@
|
||||
import { EventPublisher, PrivateKeySigner } from "@snort/system";
|
||||
import {
|
||||
EventPublisher,
|
||||
Nip46Signer,
|
||||
Nip7Signer,
|
||||
PrivateKeySigner,
|
||||
} from "@snort/system";
|
||||
import { AsyncButton } from "../components/button";
|
||||
import { useContext, useState } from "react";
|
||||
import { bech32ToHex, hexToBech32 } from "@snort/shared";
|
||||
import { openFile } from "../utils";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { Blossom } from "../blossom";
|
||||
import { loginPrivateKey } from "../login";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LoginState } from "../login";
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [name, setName] = useState("");
|
||||
@ -44,16 +49,23 @@ export default function SignUpPage() {
|
||||
picture: pic,
|
||||
});
|
||||
system.BroadcastEvent(ev);
|
||||
await loginPrivateKey(system, key);
|
||||
LoginState.loginPrivateKey(key.privateKey);
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
async function loginKey() {
|
||||
setError("");
|
||||
try {
|
||||
const key = bech32ToHex(keyIn);
|
||||
await loginPrivateKey(system, new PrivateKeySigner(key));
|
||||
navigate("/");
|
||||
if (keyIn.startsWith("nsec1")) {
|
||||
LoginState.loginPrivateKey(bech32ToHex(keyIn));
|
||||
navigate("/");
|
||||
} else if (keyIn.startsWith("bunker://")) {
|
||||
const signer = new Nip46Signer(keyIn);
|
||||
await signer.init();
|
||||
const pubkey = await signer.getPubKey();
|
||||
LoginState.loginBunker(keyIn, signer.privateKey!, pubkey);
|
||||
navigate("/");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
@ -71,9 +83,26 @@ export default function SignUpPage() {
|
||||
value={keyIn}
|
||||
onChange={(e) => setKeyIn(e.target.value)}
|
||||
/>
|
||||
<AsyncButton onClick={loginKey} disabled={!keyIn.startsWith("nsec")}>
|
||||
<AsyncButton
|
||||
onClick={loginKey}
|
||||
disabled={!keyIn.startsWith("nsec") && !keyIn.startsWith("bunker://")}
|
||||
>
|
||||
Login
|
||||
</AsyncButton>
|
||||
{window.nostr && (
|
||||
<div className="flex flex-col gap-4">
|
||||
Browser Extension:
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
const pk = await new Nip7Signer().getPubKey();
|
||||
LoginState.login(pk);
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
Nostr Extension
|
||||
</AsyncButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4 items-center my-6">
|
||||
<div className="text-xl">OR</div>
|
||||
<div className="h-[1px] bg-neutral-800 w-full"></div>
|
||||
|
@ -1,10 +1,11 @@
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { LNVpsApi, VmInstance, VmPayment } from "../api";
|
||||
import VpsInstanceRow from "../components/vps-instance";
|
||||
import useLogin from "../hooks/login";
|
||||
import { ApiUrl } from "../const";
|
||||
import { EventPublisher } from "@snort/system";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import VpsPayment from "../components/vps-payment";
|
||||
import CostLabel from "../components/cost";
|
||||
import { AsyncButton } from "../components/button";
|
||||
@ -12,8 +13,10 @@ import { AttachAddon } from "@xterm/addon-attach";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { toEui64 } from "../utils";
|
||||
import { Icon } from "../components/icon";
|
||||
import Modal from "../components/modal";
|
||||
import SSHKeySelector from "../components/ssh-keys";
|
||||
|
||||
const fit = new FitAddon();
|
||||
|
||||
@ -23,28 +26,28 @@ export default function VmPage() {
|
||||
const login = useLogin();
|
||||
const navigate = useNavigate();
|
||||
const [payment, setPayment] = useState<VmPayment>();
|
||||
const [term, setTerm] = useState<Terminal>()
|
||||
const [term, setTerm] = useState<Terminal>();
|
||||
const termRef = useRef<HTMLDivElement | null>(null);
|
||||
const [editKey, setEditKey] = useState(false);
|
||||
const [key, setKey] = useState(state?.ssh_key_id ?? -1);
|
||||
|
||||
const api = useMemo(() => {
|
||||
if (!login?.builder) return;
|
||||
return new LNVpsApi(ApiUrl, login.builder);
|
||||
}, [login]);
|
||||
|
||||
const renew = useCallback(
|
||||
async function () {
|
||||
if (!login?.signer || !state) return;
|
||||
const api = new LNVpsApi(
|
||||
ApiUrl,
|
||||
new EventPublisher(login.signer, login.pubkey),
|
||||
);
|
||||
if (!api || !state) return;
|
||||
const p = await api.renewVm(state.id);
|
||||
setPayment(p);
|
||||
},
|
||||
[login, state],
|
||||
[api, state],
|
||||
);
|
||||
|
||||
async function openTerminal() {
|
||||
if (!login?.signer || !state) return;
|
||||
const api = new LNVpsApi(
|
||||
ApiUrl,
|
||||
new EventPublisher(login.signer, login.pubkey),
|
||||
);
|
||||
if (!login?.builder || !state) return;
|
||||
const api = new LNVpsApi(ApiUrl, login.builder);
|
||||
const ws = await api.connect_terminal(state.id);
|
||||
const te = new Terminal();
|
||||
const webgl = new WebglAddon();
|
||||
@ -55,7 +58,7 @@ export default function VmPage() {
|
||||
te.loadAddon(fit);
|
||||
te.onResize(({ cols, rows }) => {
|
||||
ws.send(`${cols}:${rows}`);
|
||||
})
|
||||
});
|
||||
const attach = new AttachAddon(ws);
|
||||
te.loadAddon(attach);
|
||||
setTerm(te);
|
||||
@ -81,21 +84,9 @@ export default function VmPage() {
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<VpsInstanceRow vm={state} actions={false} />
|
||||
<VpsInstanceRow vm={state} actions={true} />
|
||||
{action === undefined && (
|
||||
<>
|
||||
<div className="text-xl">Renewal</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>{new Date(state.expires).toDateString()}</div>
|
||||
{state.template?.cost_plan && (
|
||||
<div>
|
||||
<CostLabel cost={state.template?.cost_plan} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AsyncButton onClick={() => navigate("/vm/renew", { state })}>
|
||||
Extend Now
|
||||
</AsyncButton>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="text-xl">Network:</div>
|
||||
{(state.ip_assignments?.length ?? 0) === 0 && (
|
||||
@ -118,7 +109,21 @@ export default function VmPage() {
|
||||
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
|
||||
{state.ssh_key?.name}
|
||||
</div>
|
||||
<Icon name="pencil" onClick={() => setEditKey(true)} />
|
||||
</div>
|
||||
<hr />
|
||||
<div className="text-xl">Renewal</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>{new Date(state.expires).toDateString()}</div>
|
||||
{state.template?.cost_plan && (
|
||||
<div>
|
||||
<CostLabel cost={state.template?.cost_plan} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AsyncButton onClick={() => navigate("/vm/renew", { state })}>
|
||||
Extend Now
|
||||
</AsyncButton>
|
||||
{/*
|
||||
{!term && <AsyncButton onClick={openTerminal}>Connect Terminal</AsyncButton>}
|
||||
{term && <div className="border p-2" ref={termRef}></div>}*/}
|
||||
@ -131,11 +136,8 @@ export default function VmPage() {
|
||||
<VpsPayment
|
||||
payment={payment}
|
||||
onPaid={async () => {
|
||||
if (!login?.signer || !state) return;
|
||||
const api = new LNVpsApi(
|
||||
ApiUrl,
|
||||
new EventPublisher(login.signer, login.pubkey),
|
||||
);
|
||||
if (!login?.builder || !state) return;
|
||||
const api = new LNVpsApi(ApiUrl, login.builder);
|
||||
const newState = await api.getVm(state.id);
|
||||
navigate("/vm", {
|
||||
state: newState,
|
||||
@ -146,6 +148,30 @@ export default function VmPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editKey && (
|
||||
<Modal id="edit-ssh-key" onClose={() => setEditKey(false)}>
|
||||
<SSHKeySelector selectedKey={key} setSelectedKey={setKey} />
|
||||
<div className="flex flex-col gap-4 mt-8">
|
||||
<small>After selecting a new key, please restart the VM.</small>
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
if (!state) return;
|
||||
await api?.patchVm(state.id, {
|
||||
ssh_key_id: key,
|
||||
});
|
||||
const ns = await api?.getVm(state?.id);
|
||||
navigate(".", {
|
||||
state: ns,
|
||||
replace: true,
|
||||
});
|
||||
setEditKey(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
24
src/utils.ts
24
src/utils.ts
@ -31,13 +31,23 @@ export async function openFile(): Promise<File | undefined> {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function toEui64(prefix: string, mac: string) {
|
||||
const macData = base16.decode(mac.replace(/:/g, "").toUpperCase());
|
||||
const macExtended = new Uint8Array([...macData.subarray(0, 3), 0xff, 0xfe, ...macData.subarray(3, 6)])
|
||||
const macExtended = new Uint8Array([
|
||||
...macData.subarray(0, 3),
|
||||
0xff,
|
||||
0xfe,
|
||||
...macData.subarray(3, 6),
|
||||
]);
|
||||
macExtended[0] |= 0x02;
|
||||
return (prefix + base16.encode(macExtended.subarray(0, 2)) + ":"
|
||||
+ base16.encode(macExtended.subarray(2, 4)) + ":"
|
||||
+ base16.encode(macExtended.subarray(4, 6)) + ":"
|
||||
+ base16.encode(macExtended.subarray(6, 8))).toLowerCase();
|
||||
}
|
||||
return (
|
||||
prefix +
|
||||
base16.encode(macExtended.subarray(0, 2)) +
|
||||
":" +
|
||||
base16.encode(macExtended.subarray(2, 4)) +
|
||||
":" +
|
||||
base16.encode(macExtended.subarray(4, 6)) +
|
||||
":" +
|
||||
base16.encode(macExtended.subarray(6, 8))
|
||||
).toLowerCase();
|
||||
}
|
||||
|
Reference in New Issue
Block a user