feat: signup
This commit is contained in:
@ -106,7 +106,7 @@ export class LNVpsApi {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher | undefined,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
async listVms() {
|
||||
const { data } = await this.#handleResponse<ApiResponse<Array<VmInstance>>>(
|
||||
|
71
src/blossom.ts
Normal file
71
src/blossom.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { base64, bytesToString } from "@scure/base";
|
||||
import { throwIfOffline, unixNow } from "@snort/shared";
|
||||
import { EventKind, EventPublisher } from "@snort/system";
|
||||
|
||||
export interface BlobDescriptor {
|
||||
url?: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
type?: string;
|
||||
uploaded?: number;
|
||||
}
|
||||
|
||||
export class Blossom {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher,
|
||||
) {
|
||||
this.url = new URL(this.url).toString();
|
||||
}
|
||||
|
||||
async upload(file: File) {
|
||||
const hash = await window.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
await file.arrayBuffer(),
|
||||
);
|
||||
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
|
||||
|
||||
const rsp = await this.#req("/upload", "PUT", file, tags);
|
||||
if (rsp.ok) {
|
||||
return (await rsp.json()) as BlobDescriptor;
|
||||
} else {
|
||||
const text = await rsp.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
}
|
||||
|
||||
async #req(
|
||||
path: string,
|
||||
method: "GET" | "POST" | "DELETE" | "PUT",
|
||||
body?: BodyInit,
|
||||
tags?: Array<Array<string>>,
|
||||
) {
|
||||
throwIfOffline();
|
||||
|
||||
const url = `${this.url}upload`;
|
||||
const now = unixNow();
|
||||
const auth = async (url: string, method: string) => {
|
||||
const auth = await this.publisher.generic((eb) => {
|
||||
eb.kind(24_242 as EventKind)
|
||||
.tag(["u", url])
|
||||
.tag(["method", method])
|
||||
.tag(["t", path.slice(1)])
|
||||
.tag(["expiration", (now + 10).toString()]);
|
||||
tags?.forEach((t) => eb.tag(t));
|
||||
return eb;
|
||||
});
|
||||
return `Nostr ${base64.encode(
|
||||
new TextEncoder().encode(JSON.stringify(auth)),
|
||||
)}`;
|
||||
};
|
||||
|
||||
return await fetch(url, {
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: await auth(url, method),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -5,16 +5,21 @@ import { loginNip7 } from "../login";
|
||||
import useLogin from "../hooks/login";
|
||||
import Profile from "./profile";
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { Link } from "react-router-dom";
|
||||
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 () => {
|
||||
await loginNip7(system);
|
||||
if (window.nostr) {
|
||||
await loginNip7(system);
|
||||
} else {
|
||||
navigate("/new-account");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign In
|
||||
|
@ -4,6 +4,7 @@ import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
export default function Profile({ link }: { link: NostrLink }) {
|
||||
const profile = useUserProfile(link.id);
|
||||
const name = profile?.display_name ?? profile?.name ?? "";
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<img
|
||||
@ -11,9 +12,7 @@ export default function Profile({ link }: { link: NostrLink }) {
|
||||
className="w-12 h-12 rounded-full bg-neutral-800 object-cover object-center"
|
||||
/>
|
||||
<div>
|
||||
{profile?.display_name ??
|
||||
profile?.name ??
|
||||
hexToBech32("npub", link.id).slice(0, 12)}
|
||||
{name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -11,7 +11,7 @@ export default function VmActions({ vm }: { vm: VmInstance }) {
|
||||
name={state === "running" ? "stop" : "start"}
|
||||
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
||||
size={40}
|
||||
onClick={e => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
@ -19,7 +19,7 @@ export default function VmActions({ vm }: { vm: VmInstance }) {
|
||||
name="delete"
|
||||
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
||||
size={40}
|
||||
onClick={e => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
@ -27,7 +27,7 @@ export default function VmActions({ vm }: { vm: VmInstance }) {
|
||||
name="refresh-1"
|
||||
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
||||
size={40}
|
||||
onClick={e => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
|
@ -4,16 +4,26 @@ import OsImageName from "./os-image-name";
|
||||
import VpsResources from "./vps-resources";
|
||||
import VmActions from "./vps-actions";
|
||||
|
||||
export default function VpsInstanceRow({ vm, actions }: { vm: VmInstance, actions?: boolean }) {
|
||||
export default function VpsInstanceRow({
|
||||
vm,
|
||||
actions,
|
||||
}: {
|
||||
vm: VmInstance;
|
||||
actions?: boolean;
|
||||
}) {
|
||||
const expires = new Date(vm.expires);
|
||||
const isExpired = expires <= new Date();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center rounded-xl bg-neutral-900 px-3 py-2 cursor-pointer hover:bg-neutral-800"
|
||||
onClick={() => navigate("/vm", {
|
||||
state: vm
|
||||
})}>
|
||||
<div
|
||||
className="flex justify-between items-center rounded-xl bg-neutral-900 px-3 py-2 cursor-pointer hover:bg-neutral-800"
|
||||
onClick={() =>
|
||||
navigate("/vm", {
|
||||
state: vm,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<span className="text-sm text-neutral-400">#{vm.id}</span>
|
||||
|
@ -35,3 +35,9 @@ a:hover {
|
||||
hr {
|
||||
@apply border-neutral-800;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
@apply border-none rounded-xl bg-neutral-900 p-2;
|
||||
}
|
||||
|
15
src/login.ts
15
src/login.ts
@ -2,6 +2,7 @@ import { ExternalStore } from "@snort/shared";
|
||||
import {
|
||||
EventSigner,
|
||||
Nip7Signer,
|
||||
PrivateKeySigner,
|
||||
SystemInterface,
|
||||
UserState,
|
||||
} from "@snort/system";
|
||||
@ -36,3 +37,17 @@ export async function loginNip7(system: SystemInterface) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import HomePage from "./pages/home.tsx";
|
||||
import OrderPage from "./pages/order.tsx";
|
||||
import VmPage from "./pages/vm.tsx";
|
||||
import AccountPage from "./pages/account.tsx";
|
||||
import SignUpPage from "./pages/sign-up.tsx";
|
||||
|
||||
const system = new NostrSystem({
|
||||
automaticOutboxModel: false,
|
||||
@ -29,6 +30,10 @@ const router = createBrowserRouter([
|
||||
path: "/",
|
||||
element: <HomePage />,
|
||||
},
|
||||
{
|
||||
path: "/new-account",
|
||||
element: <SignUpPage />,
|
||||
},
|
||||
{
|
||||
path: "/account",
|
||||
element: <AccountPage />,
|
||||
|
@ -146,7 +146,6 @@ export default function OrderPage() {
|
||||
<>
|
||||
<b>Add SSH Key:</b>
|
||||
<textarea
|
||||
className="border-none rounded-xl bg-neutral-900 p-2"
|
||||
rows={5}
|
||||
placeholder="ssh-[rsa|ed25519] AA== id"
|
||||
value={newKey}
|
||||
@ -154,7 +153,6 @@ export default function OrderPage() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="border-none rounded-xl bg-neutral-900 p-2"
|
||||
placeholder="Key name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
|
145
src/pages/sign-up.tsx
Normal file
145
src/pages/sign-up.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { EventPublisher, 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";
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [keyIn, setKeyIn] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [file, setFile] = useState<File>();
|
||||
const [key, setKey] = useState<PrivateKeySigner>();
|
||||
const system = useContext(SnortContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function uploadImage() {
|
||||
const f = await openFile();
|
||||
setFile(f);
|
||||
}
|
||||
|
||||
async function spawnAccount() {
|
||||
if (!key) return;
|
||||
setError("");
|
||||
const pub = new EventPublisher(key, key.getPubKey());
|
||||
|
||||
let pic = undefined;
|
||||
if (file) {
|
||||
// upload picture
|
||||
const b = new Blossom("https://nostr.download", pub);
|
||||
const up = await b.upload(file);
|
||||
if (up.url) {
|
||||
pic = up.url;
|
||||
} else {
|
||||
setError("Upload filed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ev = await pub.metadata({
|
||||
name: name,
|
||||
picture: pic,
|
||||
});
|
||||
system.BroadcastEvent(ev);
|
||||
await loginPrivateKey(system, key);
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
async function loginKey() {
|
||||
setError("");
|
||||
try {
|
||||
const key = bech32ToHex(keyIn);
|
||||
await loginPrivateKey(system, new PrivateKeySigner(key));
|
||||
navigate("/");
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <b className="text-red-500">{error}</b>}
|
||||
<h1>Login</h1>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="nsec/bunker"
|
||||
value={keyIn}
|
||||
onChange={(e) => setKeyIn(e.target.value)}
|
||||
/>
|
||||
<AsyncButton onClick={loginKey} disabled={!keyIn.startsWith("nsec")}>
|
||||
Login
|
||||
</AsyncButton>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h1>Create Account</h1>
|
||||
|
||||
<p>
|
||||
LNVPS uses nostr accounts,{" "}
|
||||
<a
|
||||
href="https://nostr.how/en/what-is-nostr"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
what is nostr?
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>Avatar</div>
|
||||
<div
|
||||
className="w-40 h-40 bg-neutral-900 rounded-xl relative cursor-pointer overflow-hidden"
|
||||
onClick={uploadImage}
|
||||
>
|
||||
<div className="absolute bg-black/50 w-full h-full hover:opacity-90 opacity-0 flex items-center justify-center">
|
||||
Upload
|
||||
</div>
|
||||
{file && (
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>Name</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Display name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
setKey(PrivateKeySigner.random());
|
||||
}}
|
||||
>
|
||||
Create Account
|
||||
</AsyncButton>
|
||||
|
||||
{key && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3>Your new key:</h3>
|
||||
<div className="font-monospace select-all">
|
||||
{hexToBech32("nsec", key.privateKey)}
|
||||
</div>
|
||||
<b>Please save this key, it CANNOT be recovered</b>
|
||||
</div>
|
||||
<AsyncButton onClick={spawnAccount}>Login</AsyncButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -42,23 +42,36 @@ export default function VmPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<VpsInstanceRow vm={state} actions={false} />
|
||||
{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="text-xl">Network</div>
|
||||
<div className="flex gap-4">
|
||||
{(state.ip_assignments?.length ?? 0) === 0 && <div className="text-sm text-red-500">No IP's assigned</div>}
|
||||
{state.ip_assignments?.map(a => <div key={a.id} className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
|
||||
{a.ip.split("/")[0]}
|
||||
</div>)}
|
||||
</div>
|
||||
</>}
|
||||
{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="text-xl">Network</div>
|
||||
<div className="flex gap-4">
|
||||
{(state.ip_assignments?.length ?? 0) === 0 && (
|
||||
<div className="text-sm text-red-500">No IP's assigned</div>
|
||||
)}
|
||||
{state.ip_assignments?.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="text-sm bg-neutral-900 px-3 py-1 rounded-lg"
|
||||
>
|
||||
{a.ip.split("/")[0]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{action === "renew" && (
|
||||
<>
|
||||
<h3>Renew VPS</h3>
|
||||
|
30
src/utils.ts
Normal file
30
src/utils.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export async function openFile(): Promise<File | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const elm = document.createElement("input");
|
||||
let lock = false;
|
||||
elm.type = "file";
|
||||
const handleInput = (e: Event) => {
|
||||
lock = true;
|
||||
const elm = e.target as HTMLInputElement;
|
||||
if ((elm.files?.length ?? 0) > 0) {
|
||||
resolve(elm.files![0]);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
elm.onchange = (e) => handleInput(e);
|
||||
elm.click();
|
||||
window.addEventListener(
|
||||
"focus",
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
if (!lock) {
|
||||
resolve(undefined);
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user