mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2025-12-19 07:14:22 +01:00
add nostr wallet auth support
This commit is contained in:
@@ -169,10 +169,10 @@ test("visit each route", async ({ page }) => {
|
||||
);
|
||||
checklist.set("/gift", true);
|
||||
|
||||
// Visit connections with AutoZap params
|
||||
const autoZapParams =
|
||||
"/settings/connections?return_to=https%3A%2F%2Fwww.zapplepay.com%2Fautozap%2Fnpub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s&name=AutoZap-jb55&budget_renewal=day&max_amount=420";
|
||||
await page.goto("http://localhost:3420" + autoZapParams);
|
||||
// Visit connections nwa params
|
||||
const nwaParams =
|
||||
"/settings/connections?nwa=nostr%2Bwalletauth%3A%2F%2Fe552dec5821ef94dc1b9138a347b4b1d8dcb595e31f5c89352e50dc11255e0f4%3Frelay%3Dwss%253A%252F%252Frelay.damus.io%252F%26secret%3D0bfe616c5e126a7c%26required_commands%3Dpay_invoice%26budget%3D21%252Fday%26identity%3D32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245";
|
||||
await page.goto("http://localhost:3420" + nwaParams);
|
||||
await expect(page.locator('[role="dialog"] h2 header').first()).toHaveText(
|
||||
"Add Connection"
|
||||
);
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import {
|
||||
createForm,
|
||||
getValue,
|
||||
required,
|
||||
setValue,
|
||||
SubmitHandler
|
||||
} from "@modular-forms/solid";
|
||||
import { NwcProfile } from "@mutinywallet/mutiny-wasm";
|
||||
import { For, Show } from "solid-js";
|
||||
|
||||
import {
|
||||
AmountEditable,
|
||||
AmountSats,
|
||||
Button,
|
||||
Checkbox,
|
||||
InfoBox,
|
||||
KeyValue,
|
||||
TextField,
|
||||
TinyText,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
|
||||
export type BudgetForm = {
|
||||
connection_name: string;
|
||||
auto_approve: boolean;
|
||||
budget_amount: string; // modular forms doesn't like bigint
|
||||
interval: "Day" | "Week" | "Month" | "Year";
|
||||
};
|
||||
|
||||
export function NWCBudgetEditor(props: {
|
||||
initialProfile?: NwcProfile;
|
||||
initialName?: string;
|
||||
initialAmount?: string;
|
||||
initialInterval?: "Day" | "Week" | "Month" | "Year";
|
||||
onSave: (value: BudgetForm) => Promise<void>;
|
||||
}) {
|
||||
const i18n = useI18n();
|
||||
|
||||
const connection_name =
|
||||
props.initialProfile?.name ?? props.initialName ?? "";
|
||||
|
||||
// If there's an initial profile, look at that, otherwise default to false
|
||||
const auto_approve =
|
||||
props.initialAmount !== undefined ||
|
||||
props.initialInterval !== undefined ||
|
||||
(props.initialProfile?.require_approval !== undefined
|
||||
? !props.initialProfile?.require_approval
|
||||
: false);
|
||||
|
||||
// prop amount -> profile editing -> subscriptions -> 0
|
||||
// (ternaries take precendence so I put it in parens)
|
||||
const budget_amount =
|
||||
props.initialAmount ??
|
||||
props.initialProfile?.budget_amount?.toString() ??
|
||||
(props.initialProfile?.index === 0 ? "21000" : "0");
|
||||
|
||||
// prop intervail -> profile editing -> subscriptions -> day
|
||||
const interval =
|
||||
props.initialInterval ??
|
||||
(props.initialProfile?.budget_period
|
||||
? (props.initialProfile?.budget_period as BudgetForm["interval"])
|
||||
: props.initialProfile?.index === 0
|
||||
? "Month"
|
||||
: "Day");
|
||||
|
||||
const [budgetForm, { Form, Field }] = createForm<BudgetForm>({
|
||||
initialValues: {
|
||||
connection_name,
|
||||
auto_approve,
|
||||
budget_amount,
|
||||
interval
|
||||
},
|
||||
validate: (values) => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (values.auto_approve && values.budget_amount === "0") {
|
||||
errors.budget_amount = i18n.t(
|
||||
"settings.connections.error_budget_zero"
|
||||
);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
});
|
||||
|
||||
const handleFormSubmit: SubmitHandler<BudgetForm> = async (
|
||||
f: BudgetForm
|
||||
) => {
|
||||
// If this throws the message will be caught by the form
|
||||
await props.onSave(f);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleFormSubmit}>
|
||||
<VStack>
|
||||
<Field
|
||||
name="connection_name"
|
||||
validate={[
|
||||
required(i18n.t("settings.connections.error_name"))
|
||||
]}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<TextField
|
||||
disabled={props.initialProfile?.name !== undefined}
|
||||
value={field.value}
|
||||
{...fieldProps}
|
||||
name="name"
|
||||
error={field.error}
|
||||
placeholder={i18n.t(
|
||||
"settings.connections.new_connection_placeholder"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="auto_approve" type="boolean">
|
||||
{(field, _fieldProps) => (
|
||||
<Checkbox
|
||||
checked={field.value || false}
|
||||
label="Auto Approve"
|
||||
onChange={(c) =>
|
||||
setValue(budgetForm, "auto_approve", c)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Show when={getValue(budgetForm, "auto_approve")}>
|
||||
<VStack>
|
||||
<TinyText>
|
||||
{i18n.t("settings.connections.careful")}
|
||||
</TinyText>
|
||||
<KeyValue key={i18n.t("settings.connections.budget")}>
|
||||
<Field name="budget_amount">
|
||||
{(field, _fieldProps) => (
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<Show
|
||||
when={
|
||||
props.initialProfile?.tag !==
|
||||
"Subscription"
|
||||
}
|
||||
fallback={
|
||||
<AmountSats
|
||||
amountSats={
|
||||
Number(field.value) || 0
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AmountEditable
|
||||
initialOpen={false}
|
||||
initialAmountSats={
|
||||
field.value || "0"
|
||||
}
|
||||
showWarnings={false}
|
||||
setAmountSats={(a) => {
|
||||
setValue(
|
||||
budgetForm,
|
||||
"budget_amount",
|
||||
a.toString()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<p class="text-sm text-m-red">
|
||||
{field.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</KeyValue>
|
||||
<KeyValue
|
||||
key={i18n.t("settings.connections.resets_every")}
|
||||
>
|
||||
<Field name="interval">
|
||||
{(field, fieldProps) => (
|
||||
<Show
|
||||
when={
|
||||
props.initialProfile?.tag !==
|
||||
"Subscription"
|
||||
}
|
||||
fallback={
|
||||
budgetForm.internal.initialValues
|
||||
.interval
|
||||
}
|
||||
>
|
||||
<select
|
||||
{...fieldProps}
|
||||
class="w-full rounded-lg bg-m-grey-750 py-2 pl-4 pr-12 text-base font-normal text-white"
|
||||
>
|
||||
<For
|
||||
each={[
|
||||
{
|
||||
label: "Day",
|
||||
value: "Day"
|
||||
},
|
||||
{
|
||||
label: "Week",
|
||||
value: "Week"
|
||||
},
|
||||
{
|
||||
label: "Month",
|
||||
value: "Month"
|
||||
},
|
||||
{
|
||||
label: "Year",
|
||||
value: "Year"
|
||||
}
|
||||
]}
|
||||
>
|
||||
{({ label, value }) => (
|
||||
<option
|
||||
value={value}
|
||||
selected={
|
||||
field.value ===
|
||||
value
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</Show>
|
||||
)}
|
||||
</Field>
|
||||
</KeyValue>
|
||||
</VStack>
|
||||
</Show>
|
||||
<Show when={budgetForm.response.message}>
|
||||
<InfoBox accent="red">
|
||||
{budgetForm.response.message}
|
||||
</InfoBox>
|
||||
</Show>
|
||||
<Button
|
||||
type="submit"
|
||||
intent="blue"
|
||||
loading={budgetForm.submitting}
|
||||
>
|
||||
{props.initialProfile
|
||||
? i18n.t("settings.connections.save_connection")
|
||||
: i18n.t("settings.connections.create_connection")}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
519
src/components/NWCEditor.tsx
Normal file
519
src/components/NWCEditor.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
import {
|
||||
createForm,
|
||||
getValue,
|
||||
required,
|
||||
setValue,
|
||||
SubmitHandler
|
||||
} from "@modular-forms/solid";
|
||||
import { BudgetPeriod, Contact, NwcProfile } from "@mutinywallet/mutiny-wasm";
|
||||
import {
|
||||
createMemo,
|
||||
createResource,
|
||||
For,
|
||||
Match,
|
||||
ResourceFetcher,
|
||||
Show,
|
||||
Switch
|
||||
} from "solid-js";
|
||||
|
||||
import {
|
||||
AmountEditable,
|
||||
AmountSats,
|
||||
Avatar,
|
||||
Button,
|
||||
Checkbox,
|
||||
InfoBox,
|
||||
KeyValue,
|
||||
LoadingShimmer,
|
||||
TextField,
|
||||
TinyText,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { fetchNostrProfile } from "~/utils";
|
||||
|
||||
type BudgetInterval = "Day" | "Week" | "Month" | "Year";
|
||||
export type BudgetForm = {
|
||||
connection_name: string;
|
||||
auto_approve: boolean;
|
||||
budget_amount: string; // modular forms doesn't like bigint
|
||||
interval: BudgetInterval;
|
||||
profileIndex?: number;
|
||||
nwaString?: string;
|
||||
};
|
||||
|
||||
type FormMode = "createnwa" | "createnwc" | "editnwc";
|
||||
type BudgetMode = "fixed" | "editable";
|
||||
|
||||
function parseNWA(nwaString?: string) {
|
||||
if (!nwaString) return undefined;
|
||||
const nwa = decodeURI(nwaString);
|
||||
if (nwa) {
|
||||
// Examples:
|
||||
// Mainnet
|
||||
// nostr+walletauth://a957bc527d4b7cea5134308412719fa675671ed38eb313adcf89b96c6982480e?relay=wss%3A%2F%2Frelay.damus.io%2F&secret=a2522b4c6d6ae729&required_commands=pay_invoice&budget=21%2Fday&identity=04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9
|
||||
// Signet
|
||||
// nostr+walletauth://e9a09c45e3d412d694796041e45cb0ab8b92edbceec459ae76376b98111c9a3c?relay=wss%3A%2F%2Frelay.damus.io%2F&secret=5f894e2db96e0c63&required_commands=pay_invoice&budget=21%2Fday&identity=024f93e1890e9e470fb729ea24426766508c0e0c5618b5b475f2d027d0814d09
|
||||
const url = new URL(nwa);
|
||||
return {
|
||||
budget: url.searchParams.get("budget"),
|
||||
identity: url.searchParams.get("identity"),
|
||||
required_commands: url.searchParams.get("required_commands")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// nwa is "day" but waila parses it as "daily"
|
||||
function mapNwaInterval(interval: string): BudgetInterval | undefined {
|
||||
switch (interval) {
|
||||
case "day":
|
||||
case "daily":
|
||||
return "Day";
|
||||
case "week":
|
||||
case "weekly":
|
||||
return "Week";
|
||||
case "month":
|
||||
case "monthly":
|
||||
return "Month";
|
||||
case "year":
|
||||
case "yearly":
|
||||
return "Year";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mapIntervalToBudgetPeriod(
|
||||
interval: "Day" | "Week" | "Month" | "Year"
|
||||
): BudgetPeriod {
|
||||
switch (interval) {
|
||||
case "Day":
|
||||
return 0;
|
||||
case "Week":
|
||||
return 1;
|
||||
case "Month":
|
||||
return 2;
|
||||
case "Year":
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
export function NWCEditor(props: {
|
||||
initialProfileIndex?: number;
|
||||
initialNWA?: string;
|
||||
onSave: (indexToOpen?: number, nwcUriForCallback?: string) => Promise<void>;
|
||||
}) {
|
||||
const [state] = useMegaStore();
|
||||
const i18n = useI18n();
|
||||
|
||||
const nwa = createMemo(() => parseNWA(props.initialNWA));
|
||||
|
||||
const formMode = createMemo(() => {
|
||||
const mode: "createnwa" | "createnwc" | "editnwc" = nwa()
|
||||
? "createnwa"
|
||||
: props.initialProfileIndex
|
||||
? "editnwc"
|
||||
: "createnwc";
|
||||
return mode;
|
||||
});
|
||||
|
||||
// NWA HANDLING SECTION
|
||||
|
||||
// for "createNwa" we need to parse the nwa and fetch the nostr profile if applicable
|
||||
const [nostrProfile] = createResource(
|
||||
() => nwa()?.identity,
|
||||
fetchNostrProfile
|
||||
);
|
||||
|
||||
const name = createMemo(() => {
|
||||
if (!nostrProfile.latest) return;
|
||||
const parsed = JSON.parse(nostrProfile.latest.content);
|
||||
const name = parsed.display_name || parsed.name;
|
||||
return name;
|
||||
});
|
||||
|
||||
const image = createMemo(() => {
|
||||
if (!nostrProfile.latest) return;
|
||||
const parsed = JSON.parse(nostrProfile.latest.content);
|
||||
const image_url = parsed.picture;
|
||||
return image_url;
|
||||
});
|
||||
|
||||
const parsedBudget = createMemo(() => {
|
||||
if (!nwa()?.budget) return;
|
||||
const [amount, interval] = nwa()!.budget!.split("/");
|
||||
return {
|
||||
amount,
|
||||
interval: mapNwaInterval(interval)
|
||||
};
|
||||
});
|
||||
|
||||
async function createNwa(f: BudgetForm) {
|
||||
if (!f.nwaString) throw new Error("We lost the NWA string!");
|
||||
try {
|
||||
await state.mutiny_wallet?.approve_nostr_wallet_auth(
|
||||
f.connection_name || "Nostr Wallet Auth",
|
||||
// can we do better than ! here?
|
||||
f.nwaString
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
props.onSave();
|
||||
}
|
||||
}
|
||||
// END NWA HANDLING SECTION
|
||||
|
||||
// REGULAR NWC STUFF
|
||||
// If the profile has a label we can fetch the contact for showing the profile image
|
||||
|
||||
const nwcProfileFetcher: ResourceFetcher<
|
||||
number,
|
||||
NwcProfile | undefined
|
||||
> = async (index, _last) => {
|
||||
console.log("fetching nwc profile", index);
|
||||
if (!index) return undefined;
|
||||
|
||||
try {
|
||||
const profile: NwcProfile | undefined =
|
||||
await state.mutiny_wallet?.get_nwc_profile(index);
|
||||
console.log(profile);
|
||||
return profile;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const [profile] = createResource(
|
||||
props.initialProfileIndex,
|
||||
nwcProfileFetcher
|
||||
);
|
||||
|
||||
// TODO: this should get the contact so we can get the image, but not getting a contact tagged on the nwc right now
|
||||
const contactFetcher: ResourceFetcher<string, Contact | undefined> = async (
|
||||
label,
|
||||
_last
|
||||
) => {
|
||||
console.log("fetching contact", label);
|
||||
if (!label) return undefined;
|
||||
|
||||
try {
|
||||
const contact: Contact | undefined =
|
||||
await state.mutiny_wallet?.get_contact(label);
|
||||
return contact;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const [contact] = createResource(profile()?.label, contactFetcher);
|
||||
|
||||
async function saveConnection(f: BudgetForm) {
|
||||
let newProfile: NwcProfile | undefined = undefined;
|
||||
if (!f.profileIndex) throw new Error("No profile index!");
|
||||
if (!f.auto_approve || f.budget_amount === "0") {
|
||||
newProfile =
|
||||
await state.mutiny_wallet?.set_nwc_profile_require_approval(
|
||||
f.profileIndex
|
||||
);
|
||||
} else {
|
||||
newProfile = await state.mutiny_wallet?.set_nwc_profile_budget(
|
||||
f.profileIndex,
|
||||
BigInt(f.budget_amount),
|
||||
mapIntervalToBudgetPeriod(f.interval)
|
||||
);
|
||||
}
|
||||
|
||||
if (!newProfile) {
|
||||
// This will be caught by the form
|
||||
throw new Error(i18n.t("settings.connections.error_connection"));
|
||||
} else {
|
||||
// Remember the index so the collapser is open after creation
|
||||
props.onSave(newProfile.index);
|
||||
}
|
||||
}
|
||||
|
||||
async function createConnection(f: BudgetForm) {
|
||||
let newProfile: NwcProfile | undefined = undefined;
|
||||
|
||||
if (!f.auto_approve || f.budget_amount === "0") {
|
||||
newProfile = await state.mutiny_wallet?.create_nwc_profile(
|
||||
f.connection_name
|
||||
);
|
||||
} else {
|
||||
newProfile = await state.mutiny_wallet?.create_budget_nwc_profile(
|
||||
f.connection_name,
|
||||
BigInt(f.budget_amount),
|
||||
mapIntervalToBudgetPeriod(f.interval),
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
if (!newProfile) {
|
||||
throw new Error(i18n.t("settings.connections.error_connection"));
|
||||
} else {
|
||||
if (newProfile.nwc_uri) {
|
||||
props.onSave(newProfile.index, newProfile.nwc_uri);
|
||||
} else {
|
||||
props.onSave(newProfile.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={formMode() === "createnwc"}>
|
||||
<NWCEditorForm
|
||||
initialValues={{
|
||||
connection_name: "",
|
||||
auto_approve: false,
|
||||
budget_amount: "0",
|
||||
interval: "Day"
|
||||
}}
|
||||
formMode={formMode()}
|
||||
budgetMode="editable"
|
||||
onSave={createConnection}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={formMode() === "createnwa"}>
|
||||
<Show
|
||||
when={nwa()?.identity ? nostrProfile()?.content : true}
|
||||
fallback={<LoadingShimmer />}
|
||||
>
|
||||
<Avatar large image_url={image()} />
|
||||
<NWCEditorForm
|
||||
initialValues={{
|
||||
connection_name: name(),
|
||||
auto_approve: nwa()?.budget ? true : false,
|
||||
budget_amount:
|
||||
nwa()?.budget && parsedBudget()?.amount
|
||||
? parsedBudget()!.amount
|
||||
: "0",
|
||||
interval: parsedBudget()?.interval ?? "Day",
|
||||
nwaString: props.initialNWA
|
||||
}}
|
||||
formMode={formMode()}
|
||||
budgetMode={nwa()?.budget ? "fixed" : "editable"}
|
||||
onSave={createNwa}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={formMode() === "editnwc"}>
|
||||
{/* FIXME: not getting the contact rn */}
|
||||
<Show when={profile()}>
|
||||
<Show when={profile()?.label && contact()?.image_url}>
|
||||
<Avatar large image_url={contact()?.image_url} />
|
||||
<pre>{JSON.stringify(contact(), null, 2)}</pre>
|
||||
</Show>
|
||||
<NWCEditorForm
|
||||
initialValues={{
|
||||
connection_name: profile()?.name ?? "",
|
||||
auto_approve: !profile()?.require_approval,
|
||||
budget_amount:
|
||||
profile()?.budget_amount?.toString() ?? "0",
|
||||
interval:
|
||||
(profile()
|
||||
?.budget_period as BudgetForm["interval"]) ??
|
||||
"Day",
|
||||
profileIndex: profile()?.index
|
||||
}}
|
||||
formMode={formMode()}
|
||||
budgetMode={
|
||||
profile()?.tag === "Subscription"
|
||||
? "fixed"
|
||||
: "editable"
|
||||
}
|
||||
onSave={saveConnection}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function NWCEditorForm(props: {
|
||||
initialValues: BudgetForm;
|
||||
formMode: FormMode;
|
||||
budgetMode: BudgetMode;
|
||||
onSave: (f: BudgetForm) => Promise<void>;
|
||||
}) {
|
||||
const i18n = useI18n();
|
||||
|
||||
const [budgetForm, { Form, Field }] = createForm<BudgetForm>({
|
||||
initialValues: props.initialValues,
|
||||
validate: (values) => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (values.auto_approve && values.budget_amount === "0") {
|
||||
errors.budget_amount = i18n.t(
|
||||
"settings.connections.error_budget_zero"
|
||||
);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
});
|
||||
|
||||
const handleFormSubmit: SubmitHandler<BudgetForm> = async (
|
||||
f: BudgetForm
|
||||
) => {
|
||||
// If this throws the message will be caught by the form
|
||||
await props.onSave({
|
||||
...f,
|
||||
profileIndex: props.initialValues.profileIndex,
|
||||
nwaString: props.initialValues.nwaString
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleFormSubmit}>
|
||||
<VStack>
|
||||
<Field
|
||||
name="connection_name"
|
||||
validate={[
|
||||
required(i18n.t("settings.connections.error_name"))
|
||||
]}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<TextField
|
||||
disabled={
|
||||
props.initialValues?.connection_name !== ""
|
||||
}
|
||||
value={field.value}
|
||||
{...fieldProps}
|
||||
name="name"
|
||||
error={field.error}
|
||||
placeholder={i18n.t(
|
||||
"settings.connections.new_connection_placeholder"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="auto_approve" type="boolean">
|
||||
{(field, _fieldProps) => (
|
||||
<Checkbox
|
||||
checked={field.value || false}
|
||||
label="Auto Approve"
|
||||
onChange={(c) =>
|
||||
setValue(budgetForm, "auto_approve", c)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Show when={getValue(budgetForm, "auto_approve")}>
|
||||
<VStack>
|
||||
<TinyText>
|
||||
{i18n.t("settings.connections.careful")}
|
||||
</TinyText>
|
||||
<KeyValue key={i18n.t("settings.connections.budget")}>
|
||||
<Field name="budget_amount">
|
||||
{(field, _fieldProps) => (
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<Show
|
||||
when={
|
||||
props.budgetMode === "editable"
|
||||
}
|
||||
fallback={
|
||||
<AmountSats
|
||||
amountSats={
|
||||
Number(field.value) || 0
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AmountEditable
|
||||
initialOpen={false}
|
||||
initialAmountSats={
|
||||
field.value || "0"
|
||||
}
|
||||
showWarnings={false}
|
||||
setAmountSats={(a) => {
|
||||
setValue(
|
||||
budgetForm,
|
||||
"budget_amount",
|
||||
a.toString()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<p class="text-sm text-m-red">
|
||||
{field.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</KeyValue>
|
||||
<KeyValue
|
||||
key={i18n.t("settings.connections.resets_every")}
|
||||
>
|
||||
<Field name="interval">
|
||||
{(field, fieldProps) => (
|
||||
<Show
|
||||
when={props.budgetMode === "editable"}
|
||||
fallback={
|
||||
budgetForm.internal.initialValues
|
||||
.interval
|
||||
}
|
||||
>
|
||||
<select
|
||||
{...fieldProps}
|
||||
class="w-full rounded-lg bg-m-grey-750 py-2 pl-4 pr-12 text-base font-normal text-white"
|
||||
>
|
||||
<For
|
||||
each={[
|
||||
{
|
||||
label: "Day",
|
||||
value: "Day"
|
||||
},
|
||||
{
|
||||
label: "Week",
|
||||
value: "Week"
|
||||
},
|
||||
{
|
||||
label: "Month",
|
||||
value: "Month"
|
||||
},
|
||||
{
|
||||
label: "Year",
|
||||
value: "Year"
|
||||
}
|
||||
]}
|
||||
>
|
||||
{({ label, value }) => (
|
||||
<option
|
||||
value={value}
|
||||
selected={
|
||||
field.value ===
|
||||
value
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</Show>
|
||||
)}
|
||||
</Field>
|
||||
</KeyValue>
|
||||
</VStack>
|
||||
</Show>
|
||||
<Show when={budgetForm.response.message}>
|
||||
<InfoBox accent="red">
|
||||
{budgetForm.response.message}
|
||||
</InfoBox>
|
||||
</Show>
|
||||
<Button
|
||||
type="submit"
|
||||
intent="blue"
|
||||
loading={budgetForm.submitting}
|
||||
>
|
||||
{props.formMode === "editnwc"
|
||||
? i18n.t("settings.connections.save_connection")
|
||||
: i18n.t("settings.connections.create_connection")}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,14 @@ import { useMegaStore } from "~/state/megaStore";
|
||||
import { fetchZaps, hexpubFromNpub } from "~/utils";
|
||||
import { timeAgo } from "~/utils/prettyPrintTime";
|
||||
|
||||
function Avatar(props: { image_url?: string }) {
|
||||
export function Avatar(props: { image_url?: string; large?: boolean }) {
|
||||
return (
|
||||
<div class="flex h-[3rem] w-[3rem] flex-none items-center justify-center self-center overflow-clip rounded-full border-b border-t border-b-white/10 border-t-white/50 bg-neutral-700 text-3xl uppercase">
|
||||
<div
|
||||
class="flex h-[3rem] w-[3rem] flex-none items-center justify-center self-center overflow-clip rounded-full border-b border-t border-b-white/10 border-t-white/50 bg-neutral-700 text-3xl uppercase"
|
||||
classList={{
|
||||
"h-[6rem] w-[6rem]": props.large
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.image_url}>
|
||||
<img src={props.image_url} alt={"image"} />
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ParsedParams = {
|
||||
node_pubkey?: string;
|
||||
lnurl?: string;
|
||||
lightning_address?: string;
|
||||
nostr_wallet_auth?: string;
|
||||
};
|
||||
|
||||
export function toParsedParams(
|
||||
@@ -56,7 +57,8 @@ export function toParsedParams(
|
||||
memo: params.memo,
|
||||
node_pubkey: params.node_pubkey,
|
||||
lnurl: params.lnurl,
|
||||
lightning_address: params.lightning_address
|
||||
lightning_address: params.lightning_address,
|
||||
nostr_wallet_auth: params.nostr_wallet_auth
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NwcProfile, type BudgetPeriod } from "@mutinywallet/mutiny-wasm";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
import { NwcProfile } from "@mutinywallet/mutiny-wasm";
|
||||
import { A, useSearchParams } from "@solidjs/router";
|
||||
import { createResource, createSignal, For, Show } from "solid-js";
|
||||
import { QRCodeSVG } from "solid-qr-code";
|
||||
|
||||
import scan from "~/assets/icons/scan.svg";
|
||||
import {
|
||||
AmountSats,
|
||||
AmountSmall,
|
||||
@@ -24,44 +25,11 @@ import {
|
||||
TinyText,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { BudgetForm, NWCBudgetEditor } from "~/components/NWCBudgetEditor";
|
||||
import { NWCEditor } from "~/components/NWCEditor";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { createDeepSignal, openLinkProgrammatically } from "~/utils";
|
||||
|
||||
function mapIntervalToBudgetPeriod(
|
||||
interval: "Day" | "Week" | "Month" | "Year"
|
||||
): BudgetPeriod {
|
||||
switch (interval) {
|
||||
case "Day":
|
||||
return 0;
|
||||
case "Week":
|
||||
return 1;
|
||||
case "Month":
|
||||
return 2;
|
||||
case "Year":
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
function mapBudgetRenewalToInterval(
|
||||
budgetRenewal?: string
|
||||
): undefined | "Day" | "Week" | "Month" | "Year" {
|
||||
if (!budgetRenewal) return undefined;
|
||||
switch (budgetRenewal) {
|
||||
case "day":
|
||||
return "Day";
|
||||
case "week":
|
||||
return "Week";
|
||||
case "month":
|
||||
return "Month";
|
||||
case "year":
|
||||
return "Year";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function Spending(props: { spent: number; remaining: number }) {
|
||||
const i18n = useI18n();
|
||||
return (
|
||||
@@ -144,7 +112,11 @@ function NwcDetails(props: {
|
||||
</Show>
|
||||
|
||||
<Show when={!props.profile.require_approval}>
|
||||
<TinyText>{i18n.t("settings.connections.careful")}</TinyText>
|
||||
<Show when={props.profile.nwc_uri}>
|
||||
<TinyText>
|
||||
{i18n.t("settings.connections.careful")}
|
||||
</TinyText>
|
||||
</Show>
|
||||
<Spending
|
||||
spent={Number(
|
||||
Number(props.profile.budget_amount || 0) -
|
||||
@@ -227,16 +199,24 @@ function Nwc() {
|
||||
});
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryName = searchParams.name;
|
||||
const [callbackDialogOpen, setCallbackDialogOpen] = createSignal(false);
|
||||
const [callbackUri, setCallbackUri] = createSignal<string>();
|
||||
|
||||
// Profile creation / editing
|
||||
const [dialogOpen, setDialogOpen] = createSignal(!!queryName);
|
||||
const [profileToOpen, setProfileToOpen] = createSignal<NwcProfile>();
|
||||
const [dialogOpen, setDialogOpen] = createSignal(
|
||||
!!searchParams.queryName || !!searchParams.nwa
|
||||
);
|
||||
|
||||
function handleToggleOpen(open: boolean) {
|
||||
setDialogOpen(open);
|
||||
// If they close the dialog clear the search params
|
||||
setSearchParams({ nwa: undefined, name: undefined });
|
||||
}
|
||||
|
||||
const [profileToOpen, setProfileToOpen] = createSignal<number>();
|
||||
|
||||
function editProfile(profile: NwcProfile) {
|
||||
setProfileToOpen(profile);
|
||||
setProfileToOpen(profile.index);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
@@ -247,74 +227,28 @@ function Nwc() {
|
||||
|
||||
const [newConnection, setNewConnection] = createSignal<number>();
|
||||
|
||||
async function createConnection(f: BudgetForm) {
|
||||
let newProfile: NwcProfile | undefined = undefined;
|
||||
|
||||
// If the form was editing, we want to call the edit methods
|
||||
if (profileToOpen()) {
|
||||
if (!f.auto_approve || f.budget_amount === "0") {
|
||||
newProfile =
|
||||
await state.mutiny_wallet?.set_nwc_profile_require_approval(
|
||||
profileToOpen()!.index
|
||||
);
|
||||
} else {
|
||||
newProfile = await state.mutiny_wallet?.set_nwc_profile_budget(
|
||||
profileToOpen()!.index,
|
||||
BigInt(f.budget_amount),
|
||||
mapIntervalToBudgetPeriod(f.interval)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!f.auto_approve || f.budget_amount === "0") {
|
||||
newProfile = await state.mutiny_wallet?.create_nwc_profile(
|
||||
f.connection_name
|
||||
);
|
||||
} else {
|
||||
newProfile =
|
||||
await state.mutiny_wallet?.create_budget_nwc_profile(
|
||||
f.connection_name,
|
||||
BigInt(f.budget_amount),
|
||||
mapIntervalToBudgetPeriod(f.interval),
|
||||
undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!newProfile) {
|
||||
// This will be caught by the form
|
||||
throw new Error(i18n.t("settings.connections.error_connection"));
|
||||
} else {
|
||||
// Remember the index so the collapser is open after creation
|
||||
setNewConnection(newProfile.index);
|
||||
}
|
||||
|
||||
setSearchParams({ name: "" });
|
||||
async function handleSave(
|
||||
indexToOpen?: number,
|
||||
nwcUriForCallback?: string
|
||||
) {
|
||||
setDialogOpen(false);
|
||||
refetch();
|
||||
|
||||
// If there's a "return_to" param we use that instead of the callbackUri scheme
|
||||
const returnUrl = searchParams.return_to;
|
||||
if (returnUrl && newProfile.nwc_uri) {
|
||||
// add the nwc query param to the return url
|
||||
const fullURI =
|
||||
returnUrl +
|
||||
(returnUrl.includes("?") ? "&" : "?") +
|
||||
"nwc=" +
|
||||
encodeURIComponent(newProfile.nwc_uri);
|
||||
|
||||
setCallbackUri(fullURI);
|
||||
setCallbackDialogOpen(true);
|
||||
if (indexToOpen) {
|
||||
setNewConnection(indexToOpen);
|
||||
}
|
||||
|
||||
const callbackUriScheme = searchParams.callbackUri;
|
||||
if (callbackUriScheme && newProfile.nwc_uri) {
|
||||
const fullURI = newProfile.nwc_uri.replace(
|
||||
if (callbackUriScheme && nwcUriForCallback) {
|
||||
const fullURI = nwcUriForCallback.replace(
|
||||
"nostr+walletconnect://",
|
||||
`${callbackUriScheme}://`
|
||||
);
|
||||
setCallbackUri(fullURI);
|
||||
setCallbackDialogOpen(true);
|
||||
}
|
||||
|
||||
setSearchParams({ nwa: undefined, name: undefined });
|
||||
}
|
||||
|
||||
async function openCallbackUri() {
|
||||
@@ -351,25 +285,17 @@ function Nwc() {
|
||||
</Show>
|
||||
<SimpleDialog
|
||||
open={dialogOpen()}
|
||||
setOpen={setDialogOpen}
|
||||
setOpen={handleToggleOpen}
|
||||
title={
|
||||
profileToOpen()
|
||||
? i18n.t("settings.connections.edit_connection")
|
||||
: i18n.t("settings.connections.add_connection")
|
||||
}
|
||||
>
|
||||
<NWCBudgetEditor
|
||||
initialName={queryName}
|
||||
initialProfile={profileToOpen()}
|
||||
onSave={createConnection}
|
||||
initialAmount={
|
||||
searchParams.max_amount
|
||||
? searchParams.max_amount
|
||||
: undefined
|
||||
}
|
||||
initialInterval={mapBudgetRenewalToInterval(
|
||||
searchParams.budget_renewal
|
||||
)}
|
||||
<NWCEditor
|
||||
initialNWA={searchParams.nwa}
|
||||
initialProfileIndex={profileToOpen()}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</SimpleDialog>
|
||||
<SimpleDialog
|
||||
@@ -391,10 +317,18 @@ export function Connections() {
|
||||
<MutinyWalletGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<div class="flex items-center justify-between">
|
||||
<BackLink
|
||||
href="/settings"
|
||||
title={i18n.t("settings.header")}
|
||||
/>
|
||||
<A
|
||||
class="rounded-lg p-2 hover:bg-white/5 active:bg-m-blue md:hidden"
|
||||
href="/scanner"
|
||||
>
|
||||
<img src={scan} alt="Scan" class="h-6 w-6" />
|
||||
</A>{" "}
|
||||
</div>
|
||||
<LargeHeader>
|
||||
{i18n.t("settings.connections.title")}
|
||||
</LargeHeader>
|
||||
|
||||
@@ -367,6 +367,16 @@ export const Provider: ParentComponent = (props) => {
|
||||
onSuccess(result.value);
|
||||
}
|
||||
}
|
||||
if (result.value?.nostr_wallet_auth) {
|
||||
console.log(
|
||||
"nostr_wallet_auth",
|
||||
result.value?.nostr_wallet_auth
|
||||
);
|
||||
navigate(
|
||||
"/settings/connections/?nwa=" +
|
||||
encodeURIComponent(result.value?.nostr_wallet_auth)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
setBetaWarned() {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* @refresh reload */
|
||||
|
||||
import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
|
||||
import { ResourceFetcher } from "solid-js";
|
||||
|
||||
@@ -114,7 +116,7 @@ async function simpleZapFromEvent(
|
||||
}
|
||||
}
|
||||
|
||||
const PRIMAL_API = import.meta.env.VITE_PRIMAL;
|
||||
export const PRIMAL_API = import.meta.env.VITE_PRIMAL;
|
||||
|
||||
async function fetchFollows(npub: string): Promise<string[]> {
|
||||
let pubkey = undefined;
|
||||
@@ -281,3 +283,36 @@ export const fetchZaps: ResourceFetcher<
|
||||
throw new Error("Failed to load zaps");
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchNostrProfile: ResourceFetcher<
|
||||
string,
|
||||
NostrProfile | undefined
|
||||
> = async (hexpub, _info) => {
|
||||
try {
|
||||
if (!PRIMAL_API)
|
||||
throw new Error("Missing PRIMAL_API environment variable");
|
||||
|
||||
const response = await fetch(PRIMAL_API, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(["user_profile", { pubkey: hexpub }])
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load profile`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
for (const object of data) {
|
||||
if (object.kind === 0) {
|
||||
return object as NostrProfile;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load profile: ", e);
|
||||
throw new Error("Failed to load profile");
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user