Add pomade signing

This commit is contained in:
Jon Staab
2025-12-17 18:43:13 -08:00
parent 48f2bb1c75
commit cd1b328b1b
20 changed files with 582 additions and 487 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
VITE_BURROW_URL=
VITE_POMADE_SIGNERS=92f258a6e1baf021408e4fe9f8ec526a93397a3616b52bd64d88ef51433a4e53,c00b809f52cdeca7e8fb7e91d5d9526a3fa44ff8e513d92e5abac3a26d88f89f,98d9454a5ef68d6b9973831a969cd564447263b4e6d27c7efe7909c40c3b4825
VITE_PLATFORM_URL=https://app.flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
+16 -2
View File
@@ -85,7 +85,8 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7"
"tippy.js": "^6.3.7",
"@pomade/core": "^0.0.5"
},
"pnpm": {
"ignoredBuiltDependencies": [
@@ -94,6 +95,19 @@
],
"onlyBuiltDependencies": [
"sharp"
]
],
"overrides": {
"@welshman/app": "link:../welshman/packages/app",
"@welshman/content": "link:../welshman/packages/content",
"@welshman/editor": "link:../welshman/packages/editor",
"@welshman/feeds": "link:../welshman/packages/feeds",
"@welshman/lib": "link:../welshman/packages/lib",
"@welshman/net": "link:../welshman/packages/net",
"@welshman/router": "link:../welshman/packages/router",
"@welshman/signer": "link:../welshman/packages/signer",
"@welshman/store": "link:../welshman/packages/store",
"@welshman/util": "link:../welshman/packages/util",
"@pomade/core": "link:../pomade/packages/core"
}
}
}
+1 -20
View File
@@ -5,32 +5,13 @@
import Landing from "@app/components/Landing.svelte"
import Toast from "@app/components/Toast.svelte"
import PrimaryNav from "@app/components/PrimaryNav.svelte"
import EmailConfirm from "@app/components/EmailConfirm.svelte"
import PasswordReset from "@app/components/PasswordReset.svelte"
import {BURROW_URL} from "@app/core/state"
import {modals, pushModal} from "@app/util/modal"
import {modals} from "@app/util/modal"
interface Props {
children: Snippet
}
const {children}: Props = $props()
if (BURROW_URL && !$pubkey) {
if ($page.url.pathname === "/confirm-email") {
pushModal(EmailConfirm, {
email: $page.url.searchParams.get("email"),
confirm_token: $page.url.searchParams.get("confirm_token"),
})
}
if ($page.url.pathname === "/reset-password") {
pushModal(PasswordReset, {
email: $page.url.searchParams.get("email"),
reset_token: $page.url.searchParams.get("reset_token"),
})
}
}
</script>
<div class="flex h-screen overflow-hidden">
-52
View File
@@ -1,52 +0,0 @@
<script lang="ts">
import {onMount} from "svelte"
import {postJson, sleep} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/util/modal"
import {BURROW_URL} from "@app/core/state"
const {email, confirm_token} = $props()
const login = () => {
pushModal(LogInPassword, {email}, {path: "/"})
}
let error = $state("")
let loading = $state(true)
onMount(async () => {
const [res] = await Promise.all([
postJson(BURROW_URL + "/user/confirm-email", {email, confirm_token}),
sleep(2000),
])
error = res.error
loading = false
})
</script>
<div class="column gap-4">
<h1 class="heading">
{#if loading}
Just a second...
{:else if error}
Oops!
{:else}
Success!
{/if}
</h1>
<p class="m-auto max-w-sm text-center">
<Spinner {loading}>
{#if loading}
Hang tight, we're checking your confirmation link.
{:else if error}
Looks like something went wrong. {error}
{:else}
You're all set - click below to log in.
{/if}
</Spinner>
</p>
<Button class="btn btn-primary" onclick={login} disabled={loading}>Continue to Login</Button>
</div>
+17 -23
View File
@@ -4,7 +4,7 @@
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
import Widget from "@assets/icons/widget-2.svg?dataurl"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
import Compass from "@assets/icons/compass-big.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@@ -13,15 +13,17 @@
import SignUp from "@app/components/SignUp.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte"
import LogInBunker from "@app/components/LogInBunker.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import LogInEmail from "@app/components/LogInEmail.svelte"
import {pushModal, clearModals} from "@app/util/modal"
import {PLATFORM_NAME, BURROW_URL} from "@app/core/state"
import {PLATFORM_NAME, POMADE_SIGNERS} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {setChecked} from "@app/util/notifications"
let signers: any[] = $state([])
let loading: string | undefined = $state()
const hasPomade = POMADE_SIGNERS.length >= 3
const disabled = $derived(loading ? true : undefined)
const signUp = () => pushModal(SignUp)
@@ -72,7 +74,7 @@
}
}
const loginWithPassword = () => pushModal(LogInPassword)
const loginWithEmail = () => pushModal(LogInEmail)
const loginWithBunker = () => pushModal(LogInBunker)
@@ -112,39 +114,31 @@
Log in with {app.name}
</Button>
{/each}
{#if BURROW_URL && !hasSigner}
<Button {disabled} onclick={loginWithPassword} class="btn btn-primary">
{#if loading === "password"}
<span class="loading loading-spinner mr-3"></span>
{:else}
<Icon icon={Key} />
{/if}
Log in with Password
{#if hasPomade && !hasSigner}
<Button {disabled} onclick={loginWithEmail} class="btn btn-primary">
<Icon icon={Letter} />
Log in with Email
</Button>
{/if}
<Button
onclick={loginWithBunker}
{disabled}
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
class="btn {hasSigner || hasPomade ? 'btn-neutral' : 'btn-primary'}">
<Icon icon={Cpu} />
Log in with Remote Signer
</Button>
{#if BURROW_URL && hasSigner}
<Button {disabled} onclick={loginWithPassword} class="btn">
{#if loading === "password"}
<span class="loading loading-spinner mr-3"></span>
{:else}
<Icon icon={Key} />
{/if}
Log in with Password
{#if hasPomade && hasSigner}
<Button {disabled} onclick={loginWithEmail} class="btn">
<Icon icon={Letter} />
Log in with Email
</Button>
{/if}
{#if !hasSigner || !BURROW_URL}
{#if !hasSigner || !hasPomade}
<Link
external
{disabled}
href="https://nostrapps.com#signers"
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
class="btn {hasSigner || hasPomade ? '' : 'btn-neutral'}">
<Icon icon={Compass} />
Browse Signer Apps
</Link>
+115
View File
@@ -0,0 +1,115 @@
<script lang="ts">
import {Client} from "@pomade/core"
import {loginWithPomade} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Letter from "@assets/icons/letter.svg?dataurl"
import Key from "@assets/icons/key.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import LogInOTP from "@app/components/LogInOTP.svelte"
import {pushModal, clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
interface Props {
email?: string
}
let {email = $bindable("")}: Props = $props()
const back = () => history.back()
const loginWithOTP = () => pushModal(LogInOTP, {email})
const onSubmit = async () => {
loading = true
try {
const {ok, options, messages, clientSecret} = await Client.loginWithPassword(email, password)
if (!ok) {
console.error(messages)
return pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
})
}
const [client, peers] = options[0]!
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
if (res.ok && clientOptions) {
loginWithPomade(clientOptions.group.group_pk.slice(2), clientOptions)
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
} else {
console.error(res.messages)
pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
})
}
} finally {
loading = false
}
}
let loading = $state(false)
let password = $state("")
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Log In</div>
{/snippet}
{#snippet info()}
<div>Log in using your email and password</div>
{/snippet}
</ModalHeader>
<FieldInline>
{#snippet label()}
<p>Email*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Password*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input type="password" bind:value={password} />
</label>
{/snippet}
</FieldInline>
<p class="text-sm">
Forgot your password? <Button class="link" onclick={loginWithOTP}>Log in with a one-time access code</Button
>.
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !email || !password}>
<Spinner {loading}>Log in</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
@@ -1,24 +1,24 @@
<script lang="ts">
import {postJson, sleep} from "@welshman/lib"
import {Client} from "@pomade/core"
import {preventDefault} from "@lib/html"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Letter from "@assets/icons/letter.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import LogInOTPConfirm from "@app/components/LogInOTPConfirm.svelte"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {BURROW_URL} from "@app/core/state"
interface Props {
email: string
email?: string
}
let {email = $bindable()}: Props = $props()
let {email = $bindable("")}: Props = $props()
const back = () => history.back()
@@ -26,16 +26,15 @@
loading = true
try {
const [res] = await Promise.all([
postJson(BURROW_URL + "/user/request-reset", {email}),
sleep(1000),
])
const {ok} = await Client.requestChallenge(email)
if (res.error) {
pushToast({message: res.error, theme: "error"})
if (ok) {
pushModal(LogInOTPConfirm, {email})
} else {
pushToast({message: `Password reset email has been sent!`})
pushModal(LogInPassword, {email}, {path: "/"})
pushToast({
theme: "error",
message: "Sorry, we were unable to request a login code.",
})
}
} finally {
loading = false
@@ -48,30 +47,31 @@
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Reset your password</div>
<div>Log In</div>
{/snippet}
{#snippet info()}
<div>Log in using a one-time login code</div>
{/snippet}
</ModalHeader>
<FieldInline disabled={loading}>
<FieldInline>
{#snippet label()}
<p>Email Address</p>
<p>Email*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={UserRounded} />
<input bind:value={email} class="grow" />
<Icon icon={Letter} />
<input bind:value={email} />
</label>
{/snippet}
{#snippet info()}
<p>You'll be sent an email with a password reset link.</p>
{/snippet}
</FieldInline>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Request password reset link</Spinner>
<Button type="submit" class="btn btn-primary" disabled={loading || !email}>
<Spinner {loading}>Log in</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import {Client} from "@pomade/core"
import {identity} from "@welshman/lib"
import {loginWithPomade} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
type Props = {
email: string
}
const {email}: Props = $props()
const back = () => history.back()
const onSubmit = async () => {
loading = true
try {
const {ok, options, messages, clientSecret} = await Client.loginWithChallenge(
email,
challenges,
)
if (!ok) {
console.error(messages)
return pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
})
}
const [client, peers] = options[0]!
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
if (res.ok && clientOptions) {
loginWithPomade(clientOptions.group.group_pk.slice(2), clientOptions)
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
} else {
console.error(res.messages)
pushToast({
theme: "error",
message: "Sorry, we were unable to log you in.",
})
}
} finally {
loading = false
}
}
const challenges = $state(["", "", ""])
let loading = $state(false)
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Log In</div>
{/snippet}
{#snippet info()}
<div>Enter the one-time login code sent to your email</div>
{/snippet}
</ModalHeader>
<FieldInline>
{#snippet label()}
<p>Login Code #1*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input bind:value={challenges[0]} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Login Code #2*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input bind:value={challenges[1]} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Login Code #3*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input bind:value={challenges[2]} />
</label>
{/snippet}
</FieldInline>
<p class="text-sm">
To keep your key as safe a possible, you will receive <strong>three separate emails</strong>.
Be sure to enter all three codes!
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !challenges.every(identity)}>
<Spinner {loading}>Log In</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
-156
View File
@@ -1,156 +0,0 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {postJson, stripProtocol} from "@welshman/lib"
import {Nip46Broker} from "@welshman/signer"
import {normalizeRelayUrl, makeSecret} from "@welshman/util"
import {addSession, makeNip46Session} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
import {clearModals, pushModal} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
import {
NIP46_PERMS,
BURROW_URL,
PLATFORM_URL,
PLATFORM_NAME,
PLATFORM_LOGO,
} from "@app/core/state"
interface Props {
email?: string
}
let {email = $bindable("")}: Props = $props()
const clientSecret = makeSecret()
const startReset = () => pushModal(PasswordResetRequest, {email})
const abortController = new AbortController()
const relays = BURROW_URL.startsWith("http://")
? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))]
: [normalizeRelayUrl(BURROW_URL)]
const broker = new Nip46Broker({clientSecret, relays})
const back = () => history.back()
const onSubmit = async () => {
loading = true
try {
const res = await postJson(BURROW_URL + "/session", {email, password, nostrconnect: url})
if (res.error) {
pushToast({message: res.error, theme: "error"})
loading = false
}
} catch (e) {
pushToast({message: "Something went wrong, please try again!", theme: "error"})
loading = false
}
}
let url = ""
let password = $state("")
let loading = $state(false)
onMount(async () => {
url = await broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await broker.waitForNostrconnect(url, abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
loading = true
const pubkey = await broker.getPublicKey()
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
addSession({...session, email})
broker.cleanup()
setChecked("*")
clearModals()
}
})
onDestroy(() => {
abortController.abort()
})
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Log In</div>
{/snippet}
{#snippet info()}
<div>Log in using your email and password</div>
{/snippet}
</ModalHeader>
<FieldInline>
{#snippet label()}
<p>Email</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={UserRounded} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Password</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input bind:value={password} type="password" />
</label>
{/snippet}
</FieldInline>
<p class="text-sm">
Your email and password only work to log in to {PLATFORM_NAME}. To use your key on other nostr
applications, visit your settings page. <Button class="link" onclick={startReset}
>Forgot your password?</Button>
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !email || !password}>
<Spinner {loading}>Next</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
-73
View File
@@ -1,73 +0,0 @@
<script lang="ts">
import {postJson, sleep} from "@welshman/lib"
import {preventDefault} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {BURROW_URL} from "@app/core/state"
const {email, reset_token} = $props()
const onSubmit = async () => {
loading = true
try {
const [res] = await Promise.all([
postJson(BURROW_URL + "/user/confirm-reset", {email, password, reset_token}),
sleep(1000),
])
if (res.error) {
pushToast({message: res.error, theme: "error"})
} else {
pushToast({message: "Password reset successfully!"})
pushModal(LogInPassword, {email}, {path: "/"})
}
} finally {
loading = false
}
}
let loading = $state(false)
let password = $state("")
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Reset your password</div>
{/snippet}
</ModalHeader>
<FieldInline disabled={loading}>
{#snippet label()}
<p>Email Address</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={UserRounded} />
<input readonly value={email} class="grow" />
</label>
{/snippet}
</FieldInline>
<FieldInline disabled={loading}>
{#snippet label()}
<p>New Password</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input bind:value={password} class="grow" type="password" />
</label>
{/snippet}
</FieldInline>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Reset password</Spinner>
</Button>
</form>
+13 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {getProfile} from "@welshman/app"
import {loadProfile} from "@welshman/app"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
type Props = {
@@ -8,14 +8,21 @@
}
const {pubkeys, size = 7}: Props = $props()
for (const pubkey of pubkeys) {
loadProfile(pubkey)
}
const visiblePubkeys = $derived.by(() => {
const filtered = pubkeys.filter(pubkey => getProfile(pubkey)?.picture)
return filtered.length > 0 ? filtered : pubkeys.slice(0, 1)
})
</script>
<div class="flex pr-3">
{#each pubkeys
.filter(p => getProfile(p)?.picture)
.toSorted()
.slice(0, 15) as pubkey (pubkey)}
<div class="z-feature -mr-3 inline-block">
{#each visiblePubkeys.toSorted().slice(0, 15) as pubkey (pubkey)}
<div class="z-feature -mr-3 inline-block flex items-center justify-center bg-base-100 rounded-full h-8 w-8">
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {size} />
</div>
{/each}
+5 -24
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import {postJson} from "@welshman/lib"
import {session} from "@welshman/app"
import {slideAndFade} from "@lib/transition"
import Link from "@lib/components/Link.svelte"
@@ -12,9 +11,7 @@
import Field from "@lib/components/Field.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {PLATFORM_NAME, BURROW_URL} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {logout} from "@app/core/commands"
import {PLATFORM_NAME} from "@app/core/state"
const email = $session?.email
@@ -24,29 +21,13 @@
loading = true
try {
const payload = {email, password, eject: true}
const res = await postJson(BURROW_URL + "/user", payload, {method: "delete"})
if (res.error) {
return pushToast({message: res.error, theme: "error"})
}
success = true
pushToast({message: "Success! Please check your messages and continue when you're ready."})
await logout()
// TODO: Implement export functionality
} finally {
loading = false
}
}
const reload = () => {
loading = true
window.location.href = "/"
}
let password = $state("")
let success = $state(false)
const success = $state(false)
let loading = $state(false)
</script>
@@ -85,7 +66,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input type="password" disabled={loading} bind:value={password} class="grow" />
<input type="password" disabled={loading} class="grow" />
</label>
{/snippet}
</Field>
@@ -97,7 +78,7 @@
Go back
</Button>
{#if success}
<Button class="btn btn-primary" disabled={loading} onclick={reload}>
<Button class="btn btn-primary" disabled={loading}>
<Icon icon={CheckCircle} />
<Spinner {loading}>Refresh the page</Spinner>
</Button>
+13 -71
View File
@@ -1,95 +1,37 @@
<script lang="ts">
import {postJson} from "@welshman/lib"
import {preventDefault} from "@lib/html"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import LogIn from "@app/components/LogIn.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte"
import SignUpProfile from "@app/components/SignUpProfile.svelte"
import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
import {pushModal} from "@app/util/modal"
import {BURROW_URL, PLATFORM_NAME} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {POMADE_SIGNERS, PLATFORM_NAME} from "@app/core/state"
const hasPomade = POMADE_SIGNERS.length >= 3
const login = () => pushModal(LogIn)
const signupPassword = async () => {
loading = true
const useEmail = () => pushModal(SignUpProfile, {flow: "email"})
try {
const res = await postJson(BURROW_URL + "/user", {email, password})
if (res.error) {
pushToast({message: res.error, theme: "error"})
} else {
pushModal(SignUpSuccess, {email}, {replaceState: true})
}
} finally {
loading = false
}
}
const usePassword = () => {
if (BURROW_URL) {
signupPassword()
}
}
const next = () => pushModal(SignUpProfile)
let email = $state("")
let password = $state("")
let loading = $state(false)
const useNostr = () => pushModal(SignUpProfile, {flow: "nostr"})
</script>
<form class="column gap-4" onsubmit={preventDefault(usePassword)}>
<div class="column gap-4">
<h1 class="heading">Sign up with Nostr</h1>
<p class="m-auto max-w-sm text-center">
{PLATFORM_NAME} is built using the
<Button class="link" onclick={() => pushModal(InfoNostr)}>nostr protocol</Button>, which gives
users control over their digital identity using <strong>cryptographic key pairs</strong>.
</p>
{#if BURROW_URL}
<FieldInline>
{#snippet label()}
<p>Email</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={UserRounded} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Password</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input bind:value={password} type="password" />
</label>
{/snippet}
</FieldInline>
<Button type="submit" class="btn btn-primary" disabled={loading || !email || !password}>
<Spinner {loading}>Sign Up</Spinner>
<Icon icon={AltArrowRight} />
{#if hasPomade}
<Button onclick={useEmail} class="btn btn-primary">
<Icon icon={Letter} />
Sign up with email
</Button>
<p class="text-sm opacity-75">
Note that your email and password will only work to log in to {PLATFORM_NAME}. To use your key
on other nostr applications, you can create a nostr key yourself, or export your key from {PLATFORM_NAME}
later.
</p>
<Divider>Or</Divider>
{/if}
<Button onclick={next} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Button onclick={useNostr} class="btn {hasPomade ? 'btn-neutral' : 'btn-primary'}">
<Icon icon={Key} />
Generate a key
</Button>
@@ -97,4 +39,4 @@
Already have an account?
<Button class="link" onclick={login}>Log in instead</Button>
</div>
</form>
</div>
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import {Client} from "@pomade/core"
import {choice} from "@welshman/lib"
import {makeSecret} from "@welshman/util"
import type {Profile} from "@welshman/util"
import {preventDefault} from "@lib/html"
import Letter from "@assets/icons/letter.svg?dataurl"
import Key from "@assets/icons/key.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpEmailConfirm from "@app/components/SignUpEmailConfirm.svelte"
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
type Props = {
profile: Profile
}
const {profile}: Props = $props()
const back = () => history.back()
const onSubmit = async () => {
if (password.trim().length < 12) {
return pushToast({
theme: "error",
message: "Password must be at least 12 characters long.",
})
}
loading = true
let client: Client | undefined = undefined
try {
const {ok, clientOptions} = await Client.register(2, 3, makeSecret())
if (!ok) {
return pushToast({
theme: "error",
message: "Failed to register! Please try again.",
})
}
client = new Client(clientOptions)
const setupRes = await client.setupRecovery(email, password)
if (!setupRes.ok) {
const message = setupRes.messages[0]?.payload.message || "Please try again."
return pushToast({
theme: "error",
message: `Failed to register! ${message}.`,
})
}
const challengeRes = await Client.requestChallenge(email, [choice(clientOptions.peers)])
if (!challengeRes.ok) {
return pushToast({
theme: "error",
message: `Failed to request confirmation code! Please try again..`,
})
}
pushModal(SignUpEmailConfirm, {email, profile, clientOptions})
} catch (e) {
console.error(e)
pushToast({
theme: "error",
message: "Failed to register! Please try again.",
})
} finally {
client?.stop()
loading = false
}
}
let email = $state("")
let password = $state("")
let loading = $state(false)
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Sign up with Email</div>
{/snippet}
{#snippet info()}
<div>Keep your keys safe using multi-signer key sharing</div>
{/snippet}
</ModalHeader>
<p>
Under the hood, nostr uses "cryptographic keypairs" to help you prove that your identity is
actually you.
</p>
<p>
If you you're not ready to take control of your keys though, that's ok! We'll keep them safe
until you are.
</p>
<FieldInline>
{#snippet label()}
<p>Email*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Password*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input type="password" bind:value={password} />
</label>
{/snippet}
</FieldInline>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" type="submit" disabled={loading || !email || !password}>
<Spinner {loading}>Continue</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
@@ -0,0 +1,83 @@
<script lang="ts">
import type {ClientOptions} from "@pomade/core"
import type {Profile} from "@welshman/util"
import {sleep} from "@welshman/lib"
import {loginWithPomade} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
type Props = {
email: string
profile: Profile
clientOptions: ClientOptions
}
const {email, clientOptions}: Props = $props()
const back = () => history.back()
const onSubmit = async () => {
loading = true
// Just pretend we're validating, they clearly got a code from somewhere
await sleep(800)
try {
loginWithPomade(clientOptions.group.group_pk.slice(2), clientOptions)
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
} finally {
loading = false
}
}
let challenge = $state("")
let loading = $state(false)
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Verify your Email Address</div>
{/snippet}
{#snippet info()}
<div>Enter the one-time confirmation code sent to your email</div>
{/snippet}
</ModalHeader>
<FieldInline>
{#snippet label()}
<p>Confirmation Code*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input bind:value={challenge} />
</label>
{/snippet}
</FieldInline>
<p class="text-sm">
We just sent a one-time confirmation code to {email}. Once you receive it, you can enter it above.
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !challenge}>
<Spinner {loading}>Log In</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+9 -1
View File
@@ -8,8 +8,15 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import SignUpKey from "@app/components/SignUpKey.svelte"
import SignUpEmail from "@app/components/SignUpEmail.svelte"
import {pushModal} from "@app/util/modal"
type Props = {
flow: "nostr" | "email"
}
const {flow}: Props = $props()
const initialValues = {
profile: makeProfile(),
shouldBroadcast: false,
@@ -17,7 +24,8 @@
const back = () => history.back()
const onsubmit = (values: {profile: Profile}) => pushModal(SignUpKey, values)
const onsubmit = (values: {profile: Profile}) =>
pushModal(flow === "nostr" ? SignUpKey : SignUpEmail, values)
</script>
<div class="flex flex-col gap-4">
-18
View File
@@ -1,18 +0,0 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/util/modal"
const {email} = $props()
const login = () => pushModal(LogInPassword)
</script>
<div class="column gap-4">
<h1 class="heading">Success!</h1>
<p class="m-auto max-w-sm text-center">
A confirmation email has been sent to {email}.
</p>
<p>Once you've confirmed your account you'll be redirected to the login page.</p>
<Button class="btn btn-primary" onclick={login}>Back to Login</Button>
</div>
+1 -1
View File
@@ -24,7 +24,7 @@
initialValues: RelayProfile
}
const {url, initialValues}: Props = $props()
const {url, initialValues = {}}: Props = $props()
const values = $state(initialValues)
+4 -8
View File
@@ -15,18 +15,14 @@
const back = () => history.back()
const tryJoin = async () => {
await addSpaceMembership(url)
broadcastUserData([url])
clearModals()
}
const join = async () => {
loading = true
try {
await tryJoin()
await addSpaceMembership(url)
broadcastUserData([url])
clearModals()
} catch (e) {
loading = false
}
+8 -3
View File
@@ -1,4 +1,5 @@
import twColors from "tailwindcss/colors"
import {context as pomadeContext} from "@pomade/core"
import {Capacitor} from "@capacitor/core"
import {get, derived, readable, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
@@ -163,7 +164,7 @@ export const PLATFORM_DESCRIPTION = import.meta.env.VITE_PLATFORM_DESCRIPTION
export const DEFAULT_BLOSSOM_SERVERS = fromCsv(import.meta.env.VITE_DEFAULT_BLOSSOM_SERVERS)
export const BURROW_URL = import.meta.env.VITE_BURROW_URL
export const POMADE_SIGNERS = fromCsv(import.meta.env.VITE_POMADE_SIGNERS)
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
@@ -233,6 +234,10 @@ export const deriveRelaySignedEvents = (url: string, filters: Filter[]) =>
// Context
pomadeContext.setSignerPubkeys(POMADE_SIGNERS)
pomadeContext.setArgonWorker(import('@pomade/core/argon-worker.js?worker'))
appContext.dufflepudUrl = DUFFLEPUD_URL
routerContext.getIndexerRelays = always(INDEXER_RELAYS)
@@ -516,9 +521,9 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
}
for (const event of metaEvents) {
const meta = readRoomMeta(event)
const meta = tryCatch(() => readRoomMeta(event))
if (gt(deletedByH.get(meta.h), meta.event.created_at)) {
if (!meta || gt(deletedByH.get(meta.h), meta.event.created_at)) {
continue
}