Add support for nsecBunker OAuth flow

This commit is contained in:
hzrd149 2024-02-01 18:02:12 +00:00
parent b15f9bc1a8
commit e8e3dc0fac
22 changed files with 548 additions and 89 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for nsecBunker OAuth flow

View File

@ -86,6 +86,8 @@ import CacheRelayView from "./views/relays/cache";
import RelaySetView from "./views/relays/relay-set";
import AppRelays from "./views/relays/app";
import MailboxesView from "./views/relays/mailboxes";
import LoginNostrAddressView from "./views/signin/address";
import LoginNostrAddressCreate from "./views/signin/address/create";
const TracksView = lazy(() => import("./views/tracks"));
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const UserVideosTab = lazy(() => import("./views/user/videos"));
@ -173,6 +175,13 @@ const router = createHashRouter([
{ path: "npub", element: <LoginNpubView /> },
{ path: "nip05", element: <LoginNip05View /> },
{ path: "nsec", element: <LoginNsecView /> },
{
path: "address",
children: [
{ path: "", element: <LoginNostrAddressView /> },
{ path: "create", element: <LoginNostrAddressCreate /> },
],
},
{ path: "nostr-connect", element: <LoginNostrConnectView /> },
],
},

View File

@ -125,6 +125,9 @@ export default class NostrMultiSubscription {
return this;
}
waitForConnection(): Promise<void> {
return Promise.all(this.relays.map((r) => r.waitForConnection())).then((v) => void 0);
}
close() {
if (this.state !== NostrMultiSubscription.OPEN) return this;

View File

@ -2,6 +2,7 @@ import { offlineMode } from "../services/offline-mode";
import relayScoreboardService from "../services/relay-scoreboard";
import { RawIncomingNostrEvent, NostrEvent, CountResponse } from "../types/nostr-event";
import { NostrOutgoingMessage } from "../types/nostr-query";
import createDefer, { Deferred } from "./deferred";
import { PersistentSubject, Subject } from "./subject";
export type IncomingEvent = {
@ -54,6 +55,8 @@ export default class Relay {
onCommandResult = new Subject<IncomingCommandResult>(undefined, false);
ws?: WebSocket;
private connectionPromises: Deferred<void>[] = [];
private connectionTimer?: () => void;
private ejectTimer?: () => void;
private intentionalClose = false;
@ -77,6 +80,9 @@ export default class Relay {
if (this.connectionTimer) {
this.connectionTimer();
this.connectionTimer = undefined;
for (const p of this.connectionPromises) p.reject();
this.connectionPromises = [];
}
// relayScoreboardService.relayTimeouts.get(this.url).addIncident();
}, CONNECTION_TIMEOUT);
@ -101,6 +107,9 @@ export default class Relay {
}
this.sendQueued();
for (const p of this.connectionPromises) p.resolve();
this.connectionPromises = [];
};
this.ws.onclose = () => {
this.onClose.next(this);
@ -129,6 +138,13 @@ export default class Relay {
this.subscriptionResTimer.clear();
}
waitForConnection(): Promise<void> {
if (this.connected) return Promise.resolve();
const p = createDefer<void>();
this.connectionPromises.push(p);
return p;
}
private startSubResTimer(sub: string) {
this.subscriptionResTimer.set(sub, relayScoreboardService.relayResponseTimes.get(this.url).createTimer());
}

View File

@ -5,7 +5,7 @@ import { useAsync } from "react-use";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { getIdenticon } from "../helpers/identicon";
import { safeUrl } from "../helpers/parse";
import { getUserDisplayName } from "../helpers/user-metadata";
import { Kind0ParsedContent, getUserDisplayName } from "../helpers/user-metadata";
import useAppSettings from "../hooks/use-app-settings";
import useCurrentAccount from "../hooks/use-current-account";
import { buildImageProxyURL } from "../helpers/image";
@ -18,41 +18,52 @@ export const UserIdenticon = memo(({ pubkey }: { pubkey: string }) => {
const RESIZE_PROFILE_SIZE = 96;
export type UserAvatarProps = Omit<AvatarProps, "src"> & {
export type UserAvatarProps = Omit<MetadataAvatarProps, "pubkey" | "metadata"> & {
pubkey: string;
relay?: string;
noProxy?: boolean;
};
export const UserAvatar = forwardRef<HTMLDivElement, UserAvatarProps>(({ pubkey, noProxy, relay, ...props }, ref) => {
const { imageProxy, proxyUserMedia, hideUsernames } = useAppSettings();
const account = useCurrentAccount();
const metadata = useUserMetadata(pubkey, relay ? [relay] : undefined);
const picture = useMemo(() => {
if (hideUsernames && pubkey !== account?.pubkey) return undefined;
if (metadata?.picture) {
const src = safeUrl(metadata?.picture);
if (src) {
const proxyURL = buildImageProxyURL(src, RESIZE_PROFILE_SIZE);
if (proxyURL) return proxyURL;
} else if (!noProxy && proxyUserMedia) {
const last4 = String(pubkey).slice(pubkey.length - 4, pubkey.length);
return `https://media.nostr.band/thumbs/${last4}/${pubkey}-picture-64`;
}
return src;
}
}, [metadata?.picture, imageProxy, proxyUserMedia, hideUsernames, account]);
return (
<Avatar
src={picture}
icon={<UserIdenticon pubkey={pubkey} />}
overflow="hidden"
title={getUserDisplayName(metadata, pubkey)}
ref={ref}
{...props}
/>
);
return <MetadataAvatar pubkey={pubkey} metadata={metadata} noProxy={noProxy} ref={ref} {...props} />;
});
UserAvatar.displayName = "UserAvatar";
export type MetadataAvatarProps = Omit<AvatarProps, "src"> & {
metadata?: Kind0ParsedContent;
pubkey?: string;
noProxy?: boolean;
};
export const MetadataAvatar = forwardRef<HTMLDivElement, MetadataAvatarProps>(
({ pubkey, metadata, noProxy, ...props }, ref) => {
const { imageProxy, proxyUserMedia, hideUsernames } = useAppSettings();
const account = useCurrentAccount();
const picture = useMemo(() => {
if (hideUsernames && pubkey && pubkey !== account?.pubkey) return undefined;
if (metadata?.picture) {
const src = safeUrl(metadata?.picture);
if (src) {
const proxyURL = buildImageProxyURL(src, RESIZE_PROFILE_SIZE);
if (proxyURL) return proxyURL;
} else if (!noProxy && proxyUserMedia && pubkey) {
const last4 = String(pubkey).slice(pubkey.length - 4, pubkey.length);
return `https://media.nostr.band/thumbs/${last4}/${pubkey}-picture-64`;
}
return src;
}
}, [metadata?.picture, imageProxy, proxyUserMedia, hideUsernames, account]);
return (
<Avatar
src={picture}
icon={pubkey ? <UserIdenticon pubkey={pubkey} /> : undefined}
overflow="hidden"
title={getUserDisplayName(metadata, pubkey ?? "")}
ref={ref}
{...props}
/>
);
},
);
UserAvatar.displayName = "UserAvatar";
export default memo(UserAvatar);

View File

@ -6,6 +6,7 @@ export type Kind0ParsedContent = {
pubkey?: string;
name?: string;
display_name?: string;
displayName?: string;
about?: string;
/** @deprecated */
image?: string;
@ -37,11 +38,11 @@ export function parseKind0Event(event: NostrEvent): Kind0ParsedContent {
export function getSearchNames(metadata: Kind0ParsedContent) {
if (!metadata) return [];
return [metadata.display_name, metadata.name].filter(Boolean) as string[];
return [metadata.displayName, metadata.display_name, metadata.name].filter(Boolean) as string[];
}
export function getUserDisplayName(metadata: Kind0ParsedContent | undefined, pubkey: string) {
return metadata?.display_name || metadata?.name || truncatedId(nip19.npubEncode(pubkey));
return metadata?.displayName || metadata?.display_name || metadata?.name || truncatedId(nip19.npubEncode(pubkey));
}
export function fixWebsiteUrl(website: string) {

View File

@ -0,0 +1,14 @@
import { kinds } from "nostr-tools";
import useTimelineLoader from "./use-timeline-loader";
import { recommendedReadRelays } from "../services/client-relays";
import useSubject from "./use-subject";
export default function useNip05Providers() {
const timeline = useTimelineLoader("nip05-providers", recommendedReadRelays, {
kinds: [kinds.Handlerinformation],
"#k": [String(kinds.NostrConnect)],
});
return useSubject(timeline.timeline);
}

View File

@ -5,10 +5,13 @@ import useSubject from "./use-subject";
import { RequestOptions } from "../services/replaceable-event-requester";
import { COMMON_CONTACT_RELAY } from "../const";
export function useUserMetadata(pubkey: string, additionalRelays: Iterable<string> = [], opts: RequestOptions = {}) {
export function useUserMetadata(pubkey?: string, additionalRelays: Iterable<string> = [], opts: RequestOptions = {}) {
const relays = useReadRelays([...additionalRelays, COMMON_CONTACT_RELAY]);
const subject = useMemo(() => userMetadataService.requestMetadata(pubkey, relays, opts), [pubkey, relays]);
const subject = useMemo(
() => (pubkey ? userMetadataService.requestMetadata(pubkey, relays, opts) : undefined),
[pubkey, relays],
);
const metadata = useSubject(subject);
return metadata;

View File

@ -14,12 +14,15 @@ export function parseAddress(address: string): { name?: string; domain?: string
type IdentityJson = {
names: Record<string, string | undefined>;
relays?: Record<string, string[]>;
nip46?: Record<string, string[]>;
};
export type DnsIdentity = {
name: string;
domain: string;
pubkey: string;
relays: string[];
hasNip46?: boolean;
nip46Relays?: string[];
};
function getIdentityFromJson(name: string, domain: string, json: IdentityJson): DnsIdentity | undefined {
@ -27,7 +30,9 @@ function getIdentityFromJson(name: string, domain: string, json: IdentityJson):
if (!pubkey) return;
const relays: string[] = json.relays?.[pubkey] ?? [];
return { name, domain, pubkey, relays };
const hasNip46 = !!json.nip46;
const nip46Relays = json.nip46?.[pubkey];
return { name, domain, pubkey, relays, nip46Relays, hasNip46 };
}
class DnsIdentityService {

View File

@ -1,4 +1,4 @@
import { finalizeEvent, generateSecretKey, getPublicKey, nip04, nip19 } from "nostr-tools";
import { finalizeEvent, generateSecretKey, getPublicKey, kinds, nip04, nip19 } from "nostr-tools";
import dayjs from "dayjs";
import { nanoid } from "nanoid";
@ -11,10 +11,16 @@ import createDefer, { Deferred } from "../classes/deferred";
import { truncatedId } from "../helpers/nostr/events";
import { NostrConnectAccount } from "./account";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { normalizeRelayURL } from "../helpers/relay";
import { safeRelayUrl } from "../helpers/relay";
import Subject from "../classes/subject";
export function isErrorResponse(response: any): response is NostrConnectErrorResponse {
return !!response.error;
}
export enum NostrConnectMethod {
Connect = "connect",
CreateAccount = "create_account",
Disconnect = "disconnect",
GetPublicKey = "get_pubic_key",
SignEvent = "sign_event",
@ -23,6 +29,7 @@ export enum NostrConnectMethod {
}
type RequestParams = {
[NostrConnectMethod.Connect]: [string] | [string, string];
[NostrConnectMethod.CreateAccount]: [string, string] | [string, string, string];
[NostrConnectMethod.Disconnect]: [];
[NostrConnectMethod.GetPublicKey]: [];
[NostrConnectMethod.SignEvent]: [string];
@ -31,6 +38,7 @@ type RequestParams = {
};
type ResponseResults = {
[NostrConnectMethod.Connect]: "ack";
[NostrConnectMethod.CreateAccount]: string;
[NostrConnectMethod.Disconnect]: "ack";
[NostrConnectMethod.GetPublicKey]: string;
[NostrConnectMethod.SignEvent]: string;
@ -43,6 +51,11 @@ export type NostrConnectResponse<N extends NostrConnectMethod> = {
result: ResponseResults[N];
error?: string;
};
export type NostrConnectErrorResponse = {
id: string;
result: string;
error: string;
};
export class NostrConnectClient {
sub: NostrMultiSubscription;
@ -50,27 +63,41 @@ export class NostrConnectClient {
isConnected = false;
pubkey: string;
provider?: string;
relays: string[];
secretKey: string;
publicKey: string;
onAuthURL = new Subject<string>(undefined, false);
supportedMethods: NostrConnectMethod[] | undefined;
constructor(pubkey: string, relays: string[], secretKey?: string) {
constructor(pubkey: string, relays: string[], secretKey?: string, provider?: string) {
this.sub = new NostrMultiSubscription(`${truncatedId(pubkey)}-nostr-connect`);
this.pubkey = pubkey;
this.relays = relays;
this.provider = provider;
this.secretKey = secretKey || bytesToHex(generateSecretKey());
this.publicKey = getPublicKey(hexToBytes(this.secretKey));
this.sub.onEvent.subscribe(this.handleEvent, this);
this.sub.setQueryMap(createSimpleQueryMap(this.relays, { kinds: [24133], "#p": [this.publicKey] }));
this.sub.setQueryMap(
createSimpleQueryMap(this.relays, {
kinds: [kinds.NostrConnect, 24134],
"#p": [this.publicKey],
}),
);
this.log("Secret Key:", this.secretKey);
this.log("Public Key:", this.publicKey);
}
open() {
async open() {
this.sub.open();
await this.sub.waitForConnection();
this.log("Connected to relays", this.relays);
}
close() {
this.sub.close();
@ -78,34 +105,35 @@ export class NostrConnectClient {
private requests = new Map<string, Deferred<any>>();
async handleEvent(event: NostrEvent) {
if (event.kind !== 24133) return;
if (this.provider && event.pubkey !== this.provider) return;
const to = event.tags.find(isPTag)?.[1];
if (!to) return;
try {
const responseStr = await nip04.decrypt(this.secretKey, this.pubkey, event.content);
const responseStr = await nip04.decrypt(this.secretKey, event.pubkey, event.content);
const response = JSON.parse(responseStr);
this.log("Got Response", response);
if (response.id) {
const p = this.requests.get(response.id);
if (!p) return;
if (response.error) {
this.log(`ERROR: Got error for ${response.id}`, response);
p.reject(new Error(response.error));
if (response.result === "auth_url") this.onAuthURL.next(response.error);
else p.reject(response);
} else if (response.result) {
this.log(response.id, response);
this.log(response.id, response.result);
p.resolve(response.result);
}
}
} catch (e) {}
}
private createEvent(content: string) {
private createEvent(content: string, target = this.pubkey, kind = kinds.NostrConnect) {
return finalizeEvent(
{
kind: 24133,
kind,
created_at: dayjs().unix(),
tags: [["p", this.pubkey]],
tags: [["p", target]],
content,
},
hexToBytes(this.secretKey),
@ -114,22 +142,44 @@ export class NostrConnectClient {
private async makeRequest<T extends NostrConnectMethod>(
method: T,
params: RequestParams[T],
kind = kinds.NostrConnect,
): Promise<ResponseResults[T]> {
const id = nanoid();
const request: NostrConnectRequest<T> = { method, id, params };
const id = nanoid(8);
const request: NostrConnectRequest<T> = { id, method, params };
const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request));
this.log(`Sending request ${id} (${method}) ${JSON.stringify(params)}`);
this.sub.sendAll(this.createEvent(encrypted));
const event = this.createEvent(encrypted, this.pubkey, kind);
this.log(`Sending request ${id} (${method}) ${JSON.stringify(params)}`, event);
this.sub.sendAll(event);
const p = createDefer<ResponseResults[T]>();
this.requests.set(id, p);
return p;
}
private async makeAdminRequest<T extends NostrConnectMethod>(
method: T,
params: RequestParams[T],
kind = 24134,
): Promise<ResponseResults[T]> {
if (!this.provider) throw new Error("Missing provider");
const id = nanoid(8);
const request: NostrConnectRequest<T> = { id, method, params };
const encrypted = await nip04.encrypt(this.secretKey, this.provider, JSON.stringify(request));
const event = this.createEvent(encrypted, this.provider, kind);
this.log(`Sending admin request ${id} (${method}) ${JSON.stringify(params)}`, event);
this.sub.sendAll(event);
const p = createDefer<ResponseResults[T]>();
this.requests.set(id, p);
return p;
}
connect(token?: string) {
this.open();
async connect(token?: string) {
await this.open();
try {
const result = this.makeRequest(NostrConnectMethod.Connect, token ? [this.publicKey, token] : [this.publicKey]);
const result = await this.makeRequest(
NostrConnectMethod.Connect,
token ? [this.publicKey, token] : [this.publicKey],
);
this.isConnected = true;
return result;
} catch (e) {
@ -138,6 +188,24 @@ export class NostrConnectClient {
throw e;
}
}
async createAccount(name: string, domain: string, email?: string) {
await this.open();
try {
const newPubkey = await this.makeAdminRequest(
NostrConnectMethod.CreateAccount,
email ? [name, domain, email] : [name, domain],
);
this.pubkey = newPubkey;
this.isConnected = true;
return newPubkey;
} catch (e) {
this.isConnected = false;
this.close();
throw e;
}
}
ensureConnected() {
if (!this.isConnected) return this.connect();
}
@ -172,21 +240,26 @@ class NostrConnectService {
if (!this.clients.includes(client)) this.clients.push(client);
}
createClient(pubkey: string, relays: string[], secretKey?: string) {
createClient(pubkey: string, relays: string[], secretKey?: string, provider?: string) {
if (this.getClient(pubkey)) throw new Error("A client for that pubkey already exists");
const client = new NostrConnectClient(pubkey, relays, secretKey);
const client = new NostrConnectClient(pubkey, relays, secretKey, provider);
client.log = this.log.extend(pubkey);
this.log(`Created client for ${pubkey} using ${relays.join(", ")}`);
return client;
}
fromHostedBunker(pubkey: string, relays: string[], provider?: string) {
return this.getClient(pubkey) || this.createClient(pubkey, relays, undefined, provider);
}
fromBunkerAddress(address: string) {
const parts = address.replace("bunker://", "").split("@");
if (parts.length !== 2) throw new Error("Invalid bunker address");
const pubkey = normalizeToHexPubkey(parts[0]);
const pathRelay = normalizeRelayURL("wss://" + parts[1]);
const pathRelay = safeRelayUrl("wss://" + parts[1]);
if (!pathRelay) throw new Error("Missing relay");
if (!pubkey || !isHexKey(pubkey)) throw new Error("Missing pubkey");
return this.getClient(pubkey) || this.createClient(pubkey, [pathRelay]);
@ -204,7 +277,7 @@ class NostrConnectService {
return this.getClient(pubkey) || this.createClient(pubkey, relays);
}
fromNsecBunkerToken(token: string) {
fromBunkerToken(token: string) {
const [npub, hexToken] = token.split("#");
const decoded = nip19.decode(npub);
const pubkey = getPubkeyFromDecodeResult(decoded);

View File

@ -61,7 +61,7 @@ if (import.meta.env.DEV) {
// random helper for logging
export function nameOrPubkey(pubkey: string) {
const parsed = userMetadataService.getSubject(pubkey).value;
return parsed?.name || parsed?.display_name || pubkey;
return parsed?.displayName || parsed?.display_name || parsed?.name || pubkey;
}
export default userMetadataService;

View File

@ -193,7 +193,7 @@ export const ProfileEditView = () => {
const defaultValues = useMemo<FormData>(
() => ({
displayName: metadata?.display_name,
displayName: metadata?.displayName || metadata?.display_name,
username: metadata?.name,
picture: metadata?.picture,
about: metadata?.about,
@ -209,7 +209,7 @@ export const ProfileEditView = () => {
name: data.username,
picture: data.picture,
};
if (data.displayName) metadata.display_name = data.displayName;
if (data.displayName) metadata.displayName = metadata.display_name = data.displayName;
if (data.about) metadata.about = data.about;
if (data.website) metadata.website = data.website;
if (data.nip05) metadata.nip05 = data.nip05;

View File

@ -0,0 +1,155 @@
import { useState } from "react";
import {
Button,
Card,
CardBody,
CardHeader,
Flex,
Heading,
Input,
InputGroup,
InputRightAddon,
LinkBox,
Tag,
Text,
useToast,
} from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { NostrEvent } from "nostr-tools";
import useNip05Providers from "../../../hooks/use-nip05-providers";
import { Kind0ParsedContent } from "../../../helpers/user-metadata";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import { getEventCoordinate } from "../../../helpers/nostr/events";
import { MetadataAvatar } from "../../../components/user-avatar";
import { ErrorBoundary } from "../../../components/error-boundary";
import dnsIdentityService from "../../../services/dns-identity";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
import nostrConnectService from "../../../services/nostr-connect";
import accountService from "../../../services/account";
function ProviderCard({ onClick, provider }: { onClick: () => void; provider: NostrEvent }) {
const metadata = JSON.parse(provider.content) as Kind0ParsedContent;
const features = provider.tags.filter((t) => t[0] === "f").map((t) => t[1]);
if (!metadata.nip05) return null;
return (
<Card as={LinkBox}>
<CardHeader p="4" display="flex" gap="2" alignItems="center">
<MetadataAvatar metadata={metadata} size="sm" />
<HoverLinkOverlay onClick={onClick} cursor="pointer">
<Heading size="sm">{metadata.displayName || metadata.name}</Heading>
</HoverLinkOverlay>
</CardHeader>
<CardBody p="4" pt="0">
<Text>{metadata.about}</Text>
{features.length > 0 && (
<Flex gap="2" mt="2">
{features.map((f) => (
<Tag key={f} colorScheme="primary">
{f}
</Tag>
))}
</Flex>
)}
</CardBody>
</Card>
);
}
export default function LoginNostrAddressCreate() {
const navigate = useNavigate();
const toast = useToast();
const [loading, setLoading] = useState<string>();
const [name, setName] = useState("");
const providers = useNip05Providers();
const [selected, setSelected] = useState<NostrEvent>();
const metadata = useUserMetadata(selected?.pubkey);
const createAccount: React.FormEventHandler<HTMLDivElement> = async (e) => {
e.preventDefault();
if (!selected || !name) return;
try {
setLoading("Creating...");
if (!metadata) throw new Error("Cant verify provider");
if (!metadata.nip05) throw new Error("Provider missing nip05 address");
const nip05 = await dnsIdentityService.fetchIdentity(metadata.nip05);
if (!nip05 || nip05.pubkey !== selected.pubkey) throw new Error("Invalid provider");
if (nip05.name !== "_") throw new Error("Provider dose not own the domain");
if (!nip05.hasNip46) throw new Error("Provider dose not support NIP-46");
const client = nostrConnectService.createClient("", nip05.nip46Relays || nip05.relays, undefined, nip05.pubkey);
client.onAuthURL.subscribe((url) => {
window.open(url, "auth", "width=400,height=600,resizable=no,status=no,location=no,toolbar=no,menubar=no");
});
const newPubkey = await client.createAccount(name, nip05.domain);
await client.connect();
nostrConnectService.saveClient(client);
accountService.addAccount({
type: "nostr-connect",
signerRelays: client.relays,
clientSecretKey: client.secretKey,
pubkey: client.pubkey,
readonly: false,
});
accountService.switchAccount(client.pubkey!);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(undefined);
};
const renderContent = () => {
if (loading) return <Text fontSize="lg">{loading}</Text>;
if (selected) {
const metadata = JSON.parse(selected.content) as Kind0ParsedContent;
const [_, domain] = metadata.nip05!.split("@");
return (
<>
<Flex gap="2" alignItems="center">
<MetadataAvatar metadata={metadata} size="sm" />
<Heading size="sm">{metadata.displayName || metadata.name}</Heading>
</Flex>
<InputGroup>
<Input name="name" isRequired value={name} onChange={(e) => setName(e.target.value)} />
<InputRightAddon as="button" onClick={() => setSelected(undefined)} cursor="pointer">
@{domain}
</InputRightAddon>
</InputGroup>
</>
);
}
return (
<>
{providers.map((p) => (
<ErrorBoundary key={getEventCoordinate(p)}>
<ProviderCard provider={p} onClick={() => setSelected(p)} />
</ErrorBoundary>
))}
</>
);
};
return (
<Flex as="form" direction="column" gap="2" onSubmit={createAccount} w="full">
{renderContent()}
<Flex justifyContent="space-between" gap="2" mt="2">
<Button variant="link" onClick={() => navigate("../")}>
Back
</Button>
<Button colorScheme="primary" ml="auto" type="submit" isLoading={!!loading} isDisabled={!selected}>
Create Account
</Button>
</Flex>
</Flex>
);
}

View File

@ -0,0 +1,157 @@
import { useState } from "react";
import { Button, Card, CardProps, Flex, FormControl, FormLabel, Image, Input, Text, useToast } from "@chakra-ui/react";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import { useDebounce } from "react-use";
import dnsIdentityService, { DnsIdentity } from "../../../services/dns-identity";
import { CheckIcon } from "../../../components/icons";
import nostrConnectService from "../../../services/nostr-connect";
import accountService from "../../../services/account";
import { COMMON_CONTACT_RELAY } from "../../../const";
import { safeRelayUrls } from "../../../helpers/relay";
export default function LoginNostrAddressView() {
const navigate = useNavigate();
const toast = useToast();
const [provider, setProvider] = useState("");
const [address, setAddress] = useState("");
const userSpecifiedDomain = address.includes("@");
const fullAddress = userSpecifiedDomain ? address || undefined : provider ? address + "@" + provider : undefined;
const [rootNip05, setRootNip05] = useState<DnsIdentity>();
const [nip05, setNip05] = useState<DnsIdentity>();
useDebounce(
async () => {
if (!fullAddress) return setNip05(undefined);
let [name, domain] = fullAddress.split("@");
if (!name || !domain || !domain.includes(".")) return setNip05(undefined);
setNip05(await dnsIdentityService.fetchIdentity(fullAddress));
setRootNip05(await dnsIdentityService.fetchIdentity(`_@${domain}`));
},
300,
[fullAddress],
);
const [loading, setLoading] = useState<string | undefined>();
const connect: React.FormEventHandler<HTMLDivElement> = async (e) => {
e.preventDefault();
if (!nip05) return;
try {
if (nip05.hasNip46) {
setLoading("Connecting...");
const relays = safeRelayUrls(nip05.nip46Relays || rootNip05?.nip46Relays || rootNip05?.relays || nip05.relays);
const client = nostrConnectService.fromHostedBunker(nip05.pubkey, relays, rootNip05?.pubkey);
client.onAuthURL.subscribe((url) => {
window.open(url, "auth", "width=400,height=600,resizable=no,status=no,location=no,toolbar=no,menubar=no");
});
await client.connect();
nostrConnectService.saveClient(client);
accountService.addAccount({
type: "nostr-connect",
signerRelays: client.relays,
clientSecretKey: client.secretKey,
pubkey: client.pubkey!,
readonly: false,
});
accountService.switchAccount(client.pubkey!);
} else {
accountService.addAccount({
type: "pubkey",
pubkey: nip05.pubkey,
relays: [...nip05.relays, COMMON_CONTACT_RELAY],
readonly: true,
});
accountService.switchAccount(nip05.pubkey);
}
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
setLoading(undefined);
};
const renderStatus = () => {
if (!address) return null;
const cardProps: CardProps = {
variant: "outline",
p: "2",
flexDirection: "row",
gap: "2",
alignItems: "center",
flexWrap: "wrap",
};
if (nip05) {
if (nip05.hasNip46) {
return (
<Card {...cardProps}>
<Image w="7" h="7" src={`//${nip05.domain}/favicon.ico`} />
<Text fontWeight="bold">{fullAddress}</Text>
<Text color="green.500" ml="auto">
Found provider <CheckIcon boxSize={5} />
</Text>
</Card>
);
} else
return (
<Card {...cardProps}>
<Text fontWeight="bold">{fullAddress}</Text>
<Text color="yellow.500" ml="auto">
Read-only
</Text>
</Card>
);
} else {
return (
<Card {...cardProps}>
<Text fontWeight="bold">{fullAddress}</Text>
<Text color="red.500" ml="auto">
Cant find identity
</Text>
</Card>
);
}
};
return (
<Flex as="form" direction="column" gap="2" onSubmit={connect} w="full">
{loading && <Text fontSize="lg">{loading}</Text>}
{!loading && (
<>
<FormControl>
<FormLabel htmlFor="address">Nostr Address</FormLabel>
<Flex gap="2">
<Input
id="address"
type="email"
isRequired
value={address}
onChange={(e) => setAddress(e.target.value)}
autoComplete="off"
/>
</Flex>
</FormControl>
{renderStatus()}
</>
)}
<Flex justifyContent="space-between" gap="2" mt="2">
<Button variant="link" onClick={() => navigate("../")}>
Back
</Button>
{nip05 ? (
<Button colorScheme="primary" ml="auto" type="submit" isLoading={!!loading} isDisabled={!nip05}>
Connect
</Button>
) : (
<Button colorScheme="primary" ml="auto" as={RouterLink} to="/signin/address/create">
Find Provider
</Button>
)}
</Flex>
</Flex>
);
}

View File

@ -1,4 +1,4 @@
import { Avatar, Center, Flex, Heading } from "@chakra-ui/react";
import { Avatar, Flex, Heading } from "@chakra-ui/react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { ReloadPrompt } from "../../components/reload-prompt";
import useSubject from "../../hooks/use-subject";
@ -13,15 +13,15 @@ export default function LoginView() {
return (
<>
<ReloadPrompt />
<Center w="full" h="full">
<Flex direction="column" alignItems="center" gap="2" maxW="sm" w="full" mx="4">
<Flex w="full" justifyContent="center">
<Flex direction="column" alignItems="center" gap="2" maxW="md" w="full" px="4" py="10">
<Avatar src="/apple-touch-icon.png" size="lg" flexShrink={0} />
<Heading size="lg" mb="2">
Sign in
</Heading>
<Outlet />
</Flex>
</Center>
</Flex>
</>
);
}

View File

@ -23,7 +23,7 @@ export default function LoginNostrConnectView() {
await client.connect();
} else if (uri.startsWith("npub")) {
client = nostrConnectService.fromNsecBunkerToken(uri);
client = nostrConnectService.fromBunkerToken(uri);
const [npub, hexToken] = uri.split("#");
await client.connect(hexToken);
} else throw new Error("Unknown format");
@ -44,12 +44,13 @@ export default function LoginNostrConnectView() {
};
return (
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} minWidth="350" w="full">
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} w="full">
{loading && <Text fontSize="lg">{loading}</Text>}
{!loading && (
<FormControl>
<FormLabel>Connect URI</FormLabel>
<Input
name="nostr-address"
placeholder="bunker://<pubkey>?relay=wss://relay.example.com"
isRequired
value={uri}

View File

@ -28,7 +28,7 @@ export default function LoginNpubView() {
};
return (
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} minWidth="350" w="full">
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} w="full">
<FormControl>
<FormLabel>Enter user npub</FormLabel>
<Input type="text" placeholder="npub1" isRequired value={npub} onChange={(e) => setNpub(e.target.value)} />

View File

@ -85,7 +85,7 @@ export default function LoginNsecView() {
};
return (
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} minWidth="350" w="full">
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} w="full">
<Alert status="warning" maxWidth="30rem">
<AlertIcon />
<Box>

View File

@ -24,6 +24,7 @@ import { COMMON_CONTACT_RELAY } from "../../const";
import accountService from "../../services/account";
import serialPortService from "../../services/serial-port";
import amberSignerService from "../../services/amber-signer";
import { AtIcon } from "../../components/icons";
export default function LoginStartView() {
const location = useLocation();
@ -109,14 +110,14 @@ export default function LoginStartView() {
if (loading) return <Spinner />;
return (
<Flex direction="column" gap="2" flexShrink={0} alignItems="center">
<>
{window.nostr && (
<Button onClick={signinWithExtension} leftIcon={<Key01 boxSize={6} />} w="sm" colorScheme="primary">
<Button onClick={signinWithExtension} leftIcon={<Key01 boxSize={6} />} w="full" colorScheme="primary">
Sign in with extension
</Button>
)}
<Button as={RouterLink} to="./nostr-connect" state={location.state} w="sm" colorScheme="blue">
Nostr Connect (NIP-46)
<Button as={RouterLink} to="./address" state={location.state} w="full" colorScheme="blue" leftIcon={<AtIcon />}>
Nostr Address
</Button>
{serialPortService.supported && (
<ButtonGroup colorScheme="purple">
@ -151,27 +152,24 @@ export default function LoginStartView() {
<Button
variant="link"
onClick={advanced.onToggle}
mt="2"
w="sm"
py="2"
w="full"
rightIcon={advanced.isOpen ? <ChevronUp /> : <ChevronDown />}
>
Show Advanced
</Button>
{advanced.isOpen && (
<>
<Button as={RouterLink} to="./nip05" state={location.state} w="sm">
DNS ID
<Badge ml="2" colorScheme="blue">
read-only
</Badge>
<Button as={RouterLink} to="./nostr-connect" state={location.state} w="full">
Nostr Connect / Bunker
</Button>
<Button as={RouterLink} to="./npub" state={location.state} w="sm">
<Button as={RouterLink} to="./npub" state={location.state} w="full">
Public key (npub)
<Badge ml="2" colorScheme="blue">
read-only
</Badge>
</Button>
<Button as={RouterLink} to="./nsec" state={location.state} w="sm">
<Button as={RouterLink} to="./nsec" state={location.state} w="full">
Secret key (nsec)
</Button>
</>
@ -179,9 +177,17 @@ export default function LoginStartView() {
<Text fontWeight="bold" mt="4">
Don't have an account?
</Text>
<Button as={RouterLink} to="/signup" state={location.state}>
<Button
as={RouterLink}
to="/signup"
state={location.state}
colorScheme="primary"
variant="outline"
maxW="xs"
w="full"
>
Sign up
</Button>
</Flex>
</>
);
}

View File

@ -88,7 +88,7 @@ export default function CreateStep({
<Flex gap="4" {...containerProps}>
<Avatar size="xl" src={preview} />
<Flex direction="column" alignItems="center">
<Heading size="md">{metadata.display_name}</Heading>
<Heading size="md">{metadata.displayName}</Heading>
{metadata.about && <Text>{metadata.about}</Text>}
</Flex>
<Button w="full" colorScheme="primary" isLoading={loading} onClick={createProfile} autoFocus>

View File

@ -33,7 +33,7 @@ export default function SignupView() {
case "profile":
return (
<ProfileImageStep
displayName={metadata.display_name}
displayName={metadata.displayName}
onSubmit={(file) => {
setProfileImage(file);
navigate("/signup/relays");

View File

@ -20,7 +20,7 @@ export default function NameStep({ onSubmit }: { onSubmit: (metadata: Kind0Parse
onSubmit({
name: username,
display_name: displayName,
displayName: displayName,
about: values.about,
});
});
@ -38,7 +38,7 @@ export default function NameStep({ onSubmit }: { onSubmit: (metadata: Kind0Parse
Next
</Button>
<Text fontWeight="bold">Already have an account?</Text>
<Button as={RouterLink} to="/signin" state={location.state}>
<Button as={RouterLink} to="/signin" state={location.state} variant="outline" w="xs" colorScheme="primary">
Sign in
</Button>
</Flex>