diff options
| author | Adam Soltys <[email protected]> | 2025-02-07 22:23:00 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2025-02-07 22:23:00 -0800 |
| commit | 479cb9ad44d05b56cd2e60735687702fc37487a0 (patch) | |
| tree | fad9e4080887c2f11e8f750714c83e619f833afa /src | |
| parent | d92352dafc6d9e62716625d75be2316039a97a69 (diff) | |
| download | coinos-ui-479cb9ad44d05b56cd2e60735687702fc37487a0.tar.gz coinos-ui-479cb9ad44d05b56cd2e60735687702fc37487a0.zip | |
nwc connections
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/AppForm.svelte | 163 | ||||
| -rw-r--r-- | src/locales/en.json | 24 | ||||
| -rw-r--r-- | src/routes/(app)/apps/[pubkey]/+page.server.ts | 26 | ||||
| -rw-r--r-- | src/routes/(app)/apps/[pubkey]/+page.svelte | 19 | ||||
| -rw-r--r-- | src/routes/(app)/apps/new/+page.server.ts | 31 | ||||
| -rw-r--r-- | src/routes/(app)/apps/new/+page.svelte | 19 | ||||
| -rw-r--r-- | src/routes/(app)/qr/[text]/+page.server.ts | 3 | ||||
| -rw-r--r-- | src/routes/(app)/settings/nostr/+page.server.ts | 7 | ||||
| -rw-r--r-- | src/routes/(app)/settings/nostr/+page.svelte | 151 |
9 files changed, 404 insertions, 39 deletions
diff --git a/src/components/AppForm.svelte b/src/components/AppForm.svelte new file mode 100644 index 00000000..ffdd3d26 --- /dev/null +++ b/src/components/AppForm.svelte @@ -0,0 +1,163 @@ +<script> + import Numpad from "$comp/Numpad.svelte"; + import { getPublicKey } from "nostr-tools"; + import { onMount, tick } from "svelte"; + import { bytesToHex, randomBytes } from "@noble/hashes/utils"; + import { goto } from "$app/navigation"; + import { copy, focus, fail, post } from "$lib/utils"; + import { t } from "$lib/translations"; + import { enhance } from "$app/forms"; + let { rate, user, name, max_amount, budget_renewal, pubkey, secret } = + $props(); + let { currency } = $derived(user); + + onMount(() => { + if (!pubkey) secret || generate(); + if (!budget_renewal) budget_renewal = "never"; + }); + + let generate = () => { + secret = bytesToHex(randomBytes(32)); + pubkey = getPublicKey(secret); + }; + + let del = async () => { + try { + await post(`/post/apps/delete`, { pubkey }); + goto("/settings/nostr"); + } catch (e) { + fail(e.message); + } + }; + + let settingBudget = $state(); + let setBudget = () => { + settingBudget = false; + }; + let done = $state(); + let startSetting = async () => { + settingBudget = true; + await tick(); + numpad.focus(); + }; + let numpad = $state(); +</script> + +<form use:enhance method="POST" class="space-y-5"> + <div> + <label for="name" class="font-bold mb-1 block">{$t("accounts.name")}</label> + <input type="text" name="name" bind:value={name} use:focus /> + </div> + + <div> + <label for="name" class="font-bold mb-1 block" + >{$t("accounts.budget")}</label + > + + <button type="button" class="flex w-full" onclick={startSetting}> + <div class="p-4 border rounded-2xl rounded-r-none border-r-0 bg-base-200"> + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" + ></iconify-icon> + </div> + <div + class="border-l-0 rounded-l-none pl-2 w-full p-4 border rounded-2xl text-left" + > + {max_amount || $t("accounts.none")} + </div> + <input type="hidden" name="max_amount" bind:value={max_amount} /> + </button> + </div> + + <div> + <label for="name" class="font-bold mb-1 block" + >{$t("accounts.renewal")}</label + > + <select name="budget_renewal" bind:value={budget_renewal}> + <option value="never" selected>{$t("accounts.never")}</option> + <option value="daily">{$t("accounts.daily")}</option> + <option value="weekly">{$t("accounts.weekly")}</option> + <option value="monthly">{$t("accounts.monthly")}</option> + <option value="yearly">{$t("accounts.yearly")}</option> + </select> + </div> + + <div> + <label for="pubkey" class="font-bold mb-1 block" + >{$t("accounts.pubkey")}</label + > + <div class="flex gap-1 items-center"> + <textarea + rows={3} + name="pubkey" + bind:value={pubkey} + class="grow" + readonly={secret} + /> + <button + type="button" + onclick={() => copy(pubkey)} + class="btn !w-auto shrink" + > + <iconify-icon icon="ph:copy-bold" width="32"></iconify-icon> + </button> + </div> + </div> + + {#if !pubkey || secret} + <div> + <label for="secret" class="font-bold mb-1 block" + >{$t("accounts.secret")}</label + > + <div class="flex gap-1 items-center"> + <textarea rows={3} name="secret" bind:value={secret} lcass="grow" /> + + <div class="space-y-2 w-24"> + <button type="button" onclick={generate} class="btn"> + <iconify-icon noobserver icon="ph:dice-three-bold" width="32" + ></iconify-icon> + </button> + <button type="button" onclick={() => copy(secret)} class="btn"> + <iconify-icon icon="ph:copy-bold" width="32"></iconify-icon> + </button> + </div> + </div> + </div> + {/if} + + <div class="space-y-2"> + <button type="submit" class="btn btn-accent">{$t("payments.submit")}</button + > + {#if pubkey} + <button type="button" class="btn" onclick={del}> + <iconify-icon icon="ph:trash-bold" width="32"></iconify-icon> + {$t("accounts.delete")}</button + > + {/if} + </div> +</form> + +{#if settingBudget} + <div + class="fixed bg-base-100 bg-opacity-90 inset-0 overflow-y-auto h-full w-full z-50 max-w-lg mx-auto" + > + <div class="relative p-5 border shadow-lg rounded-md bg-base-100 space-y-5"> + <h1 class="text-center text-2xl font-semibold"> + {$t("accounts.budget")} + </h1> + <Numpad + bind:amount={max_amount} + {currency} + {rate} + bind:submit={done} + bind:element={numpad} + /> + + <button bind:this={done} type="button" onclick={setBudget} class="btn" + >Ok</button + > + </div> + </div> +{/if} diff --git a/src/locales/en.json b/src/locales/en.json index 27736963..1725abf0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4,6 +4,25 @@ "notFound": "No notes found" }, "accounts": { + "per": "per", + "none": "None", + "day": "day", + "week": "week", + "month": "month", + "year": "year", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "yearly": "Yearly", + "never": "Never", + "newConection": "New connection", + "name": "Name", + "pubkey": "Pubkey", + "secret": "Secret", + "connection": "Connection", + "budget": "Budget", + "delete": "Delete", + "renewal": "Renewal period", "showQR": "Show QR", "revealNwc": "Reveal connection string", "finish": "Finish", @@ -206,7 +225,7 @@ "moreDetails": "Profile details", "noMessages": "You have not sent or received any messages", "profilePage": "Profile page", - "nwc": "Nostr wallet connect", + "nwc": "Nostr wallet connections", "nostrPubkey": "Nostr pubkey", "requestPayment": "Request Payment", "welcome": "Welcome! Your account balance is empty. You can start by making a deposit or by asking someone to pay you.", @@ -257,6 +276,7 @@ "placeholder": "Enter an address, invoice or username" }, "settings": { + "name": "Name", "integrations": "Integrations", "syncWithExtension": "Sync with extension", "connectNostr": "Nostr Wallet Connect", @@ -285,7 +305,7 @@ "nostrKeys": "Nostr secret key", "nostrDescription": "Your nostr secret key is used to sign and decrypt messages, keep it safe or someone may be able to impersonate you.", "nwc": "Nostr wallet connect", - "nwcDescription": "Enable your nostr app to send payments from your account", + "nwcDescription": "Enable nostr apps to send payments from your account", "seedDescription": "This 12 word seed phrase is used to derive your nostr private key and your self-custodial wallet keys which determine your auto-withdrawal addresses. Back it up and don't let anyone see it.", "emailDescription": "For password resets and payment notifications only", "hideNsec": "Hide nostr secret key", diff --git a/src/routes/(app)/apps/[pubkey]/+page.server.ts b/src/routes/(app)/apps/[pubkey]/+page.server.ts new file mode 100644 index 00000000..a2cc50e7 --- /dev/null +++ b/src/routes/(app)/apps/[pubkey]/+page.server.ts @@ -0,0 +1,26 @@ +import getRates from "$lib/rates"; +import { auth, fd, get, post } from "$lib/utils"; +import { fail, redirect } from "@sveltejs/kit"; + +export const load = async ({ cookies, params, parent }) => { + const { pubkey } = params; + const { user } = await parent(); + const rates = await getRates(); + const rate = rates[user.currency]; + const app = await get(`/app/${pubkey}`, auth(cookies)); + return { app, rate }; +}; + +export const actions = { + default: async ({ cookies, request }) => { + try { + const body = await fd(request); + await post("/app", body, auth(cookies)); + } catch (e) { + const { message: error } = e as Error; + return fail(400, { error }); + } + + redirect(307, "/settings/nostr"); + }, +}; diff --git a/src/routes/(app)/apps/[pubkey]/+page.svelte b/src/routes/(app)/apps/[pubkey]/+page.svelte new file mode 100644 index 00000000..d1a5f2bd --- /dev/null +++ b/src/routes/(app)/apps/[pubkey]/+page.svelte @@ -0,0 +1,19 @@ +<script> + import { t } from "$lib/translations"; + import AppForm from "$comp/AppForm.svelte"; + import { untrack } from "svelte"; + let { data, form } = $props(); + let { app, rate, user } = $derived(data); +</script> + +<div class="container px-4 max-w-lg mx-auto space-y-5 mt-20"> + <h2 class="text-2xl font-bold mb-2">{$t("accounts.connection")}</h2> + + {#if form?.error} + <div class="text-red-600 text-center"> + {form.error} + </div> + {/if} + + <AppForm {...app} {rate} {user} /> +</div> diff --git a/src/routes/(app)/apps/new/+page.server.ts b/src/routes/(app)/apps/new/+page.server.ts new file mode 100644 index 00000000..7c335f8c --- /dev/null +++ b/src/routes/(app)/apps/new/+page.server.ts @@ -0,0 +1,31 @@ +import getRates from "$lib/rates"; +import { auth, fd, post } from "$lib/utils"; +import { fail, redirect } from "@sveltejs/kit"; + +export const load = async ({ parent, url }) => { + const app = { + name: url.searchParams.get("name"), + pubkey: url.searchParams.get("pubkey"), + max_amount: url.searchParams.get("max_amount"), + budget_renewal: url.searchParams.get("budget_renewal"), + }; + + const { user } = await parent(); + const rates = await getRates(); + const rate = rates[user.currency]; + return { app, rate }; +}; + +export const actions = { + default: async ({ cookies, request }) => { + try { + const body = await fd(request); + await post("/app", body, auth(cookies)); + } catch (e) { + const { message: error } = e as Error; + return fail(400, { error }); + } + + redirect(307, "/settings/nostr"); + }, +}; diff --git a/src/routes/(app)/apps/new/+page.svelte b/src/routes/(app)/apps/new/+page.svelte new file mode 100644 index 00000000..d1a5f2bd --- /dev/null +++ b/src/routes/(app)/apps/new/+page.svelte @@ -0,0 +1,19 @@ +<script> + import { t } from "$lib/translations"; + import AppForm from "$comp/AppForm.svelte"; + import { untrack } from "svelte"; + let { data, form } = $props(); + let { app, rate, user } = $derived(data); +</script> + +<div class="container px-4 max-w-lg mx-auto space-y-5 mt-20"> + <h2 class="text-2xl font-bold mb-2">{$t("accounts.connection")}</h2> + + {#if form?.error} + <div class="text-red-600 text-center"> + {form.error} + </div> + {/if} + + <AppForm {...app} {rate} {user} /> +</div> diff --git a/src/routes/(app)/qr/[text]/+page.server.ts b/src/routes/(app)/qr/[text]/+page.server.ts index 5a9debb3..210c3c45 100644 --- a/src/routes/(app)/qr/[text]/+page.server.ts +++ b/src/routes/(app)/qr/[text]/+page.server.ts @@ -1,3 +1,4 @@ export async function load({ params }) { - return params; + const { text } = params; + return { text }; } diff --git a/src/routes/(app)/settings/nostr/+page.server.ts b/src/routes/(app)/settings/nostr/+page.server.ts index b4bb124b..1767ebaf 100644 --- a/src/routes/(app)/settings/nostr/+page.server.ts +++ b/src/routes/(app)/settings/nostr/+page.server.ts @@ -1,9 +1,10 @@ -import { get } from "$lib/utils"; import updateUser from "$lib/settings"; +import { auth, get } from "$lib/utils"; -export const load = async () => { +export const load = async ({ cookies }) => { const { challenge } = await get("/challenge"); - return { challenge }; + const apps = await get("/apps", auth(cookies)); + return { apps, challenge }; }; export const actions = { diff --git a/src/routes/(app)/settings/nostr/+page.svelte b/src/routes/(app)/settings/nostr/+page.svelte index 20c18c6f..6b4c2f12 100644 --- a/src/routes/(app)/settings/nostr/+page.svelte +++ b/src/routes/(app)/settings/nostr/+page.svelte @@ -1,10 +1,11 @@ <script> + import { untrack } from "svelte"; import { browser } from "$app/environment"; import { getNsec } from "$lib/nostr"; import { page } from "$app/stores"; import { tick } from "svelte"; import { t } from "$lib/translations"; - import { copy, fail } from "$lib/utils"; + import { s, copy, fail } from "$lib/utils"; import { PUBLIC_COINOS_PUBKEY as pk, PUBLIC_COINOS_RELAY as relay, @@ -12,9 +13,10 @@ import { signer, save } from "$lib/store"; let { data } = $props(); - let { challenge, user } = $derived(data); - let { npub } = $state(user); + let { apps, challenge, user } = $derived(data); + let npub = $state(untrack(() => user.npub)); let extensionAvailable = $derived(browser && window.nostr); + let { locale } = $derived(user); let newNsec = $state(), nsec = $state(), @@ -31,14 +33,6 @@ } }; - let lud16 = $derived(`${user.username}@${$page.url.host}`); - let nwc = $derived( - `nostr+walletconnect://${pk}?relay=${encodeURIComponent( - relay, - )}&secret=${user.nwc}&lud16=${lud16}`, - ); - let toggleNwc = () => (revealNwc = !revealNwc); - let extension = $state(); let getPubkey = async () => { $signer = { method: "extension", ready: true }; @@ -47,30 +41,114 @@ await tick(); $save.click(); }; + + let units = (r) => + ({ + daily: $t("accounts.day"), + weekly: $t("accounts.week"), + monthly: $t("accounts.month"), + yearly: $t("accounts.year"), + })[r]; </script> -<div> - <input type="hidden" name="challenge" value={challenge} /> - <input type="hidden" name="extension" value={extension} /> - <label for="nwc" class="font-bold">{$t("user.settings.nwc")}</label> +<input type="hidden" name="challenge" value={challenge} /> +<input type="hidden" name="extension" value={extension} /> - <p class="text-secondary mb-1"> +<div class="p-4"> + <h2 class="text-2xl font-bold mb-2"> + {$t("user.settings.nwc")} + </h2> + <p class="text-secondary mb-4"> {$t("user.settings.nwcDescription")} </p> - <div class="flex flex-wrap justify-center gap-2"> - <a href={nwc} class="btn grow"> - <img src="/images/nostr.png" class="w-8" /> - <div class="my-auto">{$t("user.settings.connectNostr")}</div> - </a> + <div class="space-y-2"> + {#each apps as connection, i} + {@const last = i === apps.length - 1} + <div class="p-4" class:border-b-8={!last}> + <div class="flex justify-center gap-2 items-center p-4"> + <div class="grow text-xl break-words min-w-0">{connection.name}</div> + {#if connection.max_amount > 0} + <div class="flex text-xl gap-1"> + <div class="flex items-center font-bold"> + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" + ></iconify-icon> + {s(connection.max_amount, locale)} + </div> - {#if !revealNwc} - <button onclick={toggleNwc} type="button" class="btn grow"> - <iconify-icon noobserver icon="ph:warning-bold" width="32" - ></iconify-icon> - <div class="my-auto">{$t("accounts.revealNwc")}</div></button - > - {/if} + {#if connection.budget_renewal !== "never"} + <div> + {connection.budget_renewal} + </div> + {/if} + </div> + {/if} + </div> + + <div class="flex flex-wrap justify-center gap-1"> + <a + href={connection.nwc} + class="btn bg-gradient-to-tr from-purple-500 to-pink-500 !w-auto shrink text-white grow whitespace-nowrap" + class:btn-disabled={!connection.secret} + aria-label="Open nostr" + > + <iconify-icon icon="ph:arrow-square-out-bold" width="32" + ></iconify-icon> + <div>Connect</div> + </a> + + <div class="flex gap-1 w-full"> + <a + href={`/apps/${connection.pubkey}`} + class="btn !w-auto grow" + aria-label="Edit" + > + <iconify-icon icon="ph:pencil-bold" width="32"></iconify-icon> + </a> + + <a + href={`/apps/${connection.pubkey}/payments`} + aria-label="Payments" + class="btn !w-auto grow" + class:btn-disabled={!connection.secret} + title={$t("accounts.copy")} + > + <iconify-icon icon="ph:clock-bold" width="32"></iconify-icon> + </a> + + <button + aria-label="Copy" + type="button !w-auto grow" + onclick={() => copy(connection.nwc)} + class="btn !w-auto grow" + class:btn-disabled={!connection.secret} + title={$t("accounts.copy")} + > + <iconify-icon icon="ph:copy-bold" width="32"></iconify-icon> + </button> + + <a + aria-label="QR" + href={`/qr/${encodeURIComponent(connection.nwc)}`} + class="btn !w-auto grow" + class:btn-disabled={!connection.secret} + title={$t("user.receive.showQR")} + disabled={!connection.secret} + > + <iconify-icon icon="ph:qr-code-bold" width="32"></iconify-icon> + </a> + </div> + </div> + </div> + {/each} + + <a href="/apps/new" class="btn"> + <iconify-icon icon="ph:plus-bold" width="32"></iconify-icon> + {$t("accounts.newConection")} + </a> </div> </div> @@ -93,13 +171,21 @@ <div class="space-y-2"> <div class="font-bold">{$t("user.nostrPubkey")}</div> <div class="flex gap-4"> - <textarea name="pubkey" bind:value={npub}> </textarea> + <textarea name="pubkey" bind:value={npub} rows={3}> </textarea> <div class="flex my-auto gap-1"> - <button type="button" class="my-auto" onclick={() => copy(npub)} + <button + type="button" + class="my-auto btn btn-circle" + onclick={() => copy(npub)} + aria-label="Copy" ><iconify-icon noobserver icon="ph:copy-bold" width="32" ></iconify-icon></button > - <a href={`/qr/${encodeURIComponent(npub)}`} class="my-auto"> + <a + href={`/qr/${encodeURIComponent(npub)}`} + class="my-auto btn btn-circle" + aria-label="QR" + > <iconify-icon noobserver icon="ph:qr-code-bold" width="32" ></iconify-icon> </a> @@ -108,8 +194,7 @@ {#if extensionAvailable} <button class="btn" type="button" onclick={getPubkey}> - <iconify-icon icon="lucide-lab:bee" width="32" class="text-yellow-400" - ></iconify-icon> + <img src="/images/alby.svg" width="32" /> {$t("user.settings.syncWithExtension")}</button > {/if} |
