feat: signup

This commit is contained in:
2024-11-29 10:47:40 +00:00
parent 12c3ddc31d
commit fc1962defc
13 changed files with 330 additions and 33 deletions

View File

@ -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
View 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),
},
});
}
}

View File

@ -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

View File

@ -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>
);

View File

@ -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();
}}
/>

View File

@ -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>

View File

@ -35,3 +35,9 @@ a:hover {
hr {
@apply border-neutral-800;
}
input,
textarea,
select {
@apply border-none rounded-xl bg-neutral-900 p-2;
}

View File

@ -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");
}
}

View File

@ -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 />,

View File

@ -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
View 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>
);
}

View File

@ -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
View 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 },
);
});
}