secure privkey

This commit is contained in:
Ren Amamiya 2023-07-09 16:55:25 +07:00
parent a5c2ec080a
commit cba94ab99e
20 changed files with 1011 additions and 499 deletions

14
src-tauri/Cargo.lock generated
View File

@ -2452,6 +2452,7 @@ version = "1.0.1"
dependencies = [
"cocoa",
"objc",
"rand 0.8.5",
"rust-argon2",
"serde",
"serde_json",
@ -2461,7 +2462,6 @@ dependencies = [
"tauri-plugin-autostart",
"tauri-plugin-single-instance",
"tauri-plugin-sql",
"tauri-plugin-store",
"tauri-plugin-stronghold",
]
@ -4847,18 +4847,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-store"
version = "0.0.0"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#0d0ed7b9075ee21f37d787217fba3ef0784b2449"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"thiserror",
]
[[package]]
name = "tauri-plugin-stronghold"
version = "0.0.0"

View File

@ -16,13 +16,13 @@ tauri-build = { version = "1.2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = [ "clipboard-read-text", "clipboard-write-text", "dialog-open", "fs-read-dir", "fs-read-file", "http-all", "http-multipart", "notification-all", "os-all", "process-relaunch", "shell-open", "system-tray", "updater", "window-close", "window-start-dragging"] }
tauri = { version = "1.2", features = [ "path-all", "fs-read-dir", "fs-read-file", "clipboard-read-text", "clipboard-write-text", "dialog-open", "http-all", "http-multipart", "notification-all", "os-all", "process-relaunch", "shell-open", "system-tray", "updater", "window-close", "window-start-dragging"] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
sqlx-cli = {version = "0.7.0", default-features = false, features = ["sqlite"] }
rust-argon2 = "1.0"
rand = "0.8.5"
[dependencies.tauri-plugin-sql]
git = "https://github.com/tauri-apps/plugins-workspace"

View File

@ -7,6 +7,7 @@
#[macro_use]
extern crate objc;
// use rand::distributions::{Alphanumeric, DistString};
use tauri::{Manager, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_sql::{Migration, MigrationKind};
@ -121,8 +122,13 @@ fn main() {
..Default::default()
};
let key = argon2::hash_raw(password.as_ref(), b"SALT_TODO", &config)
.expect("failed to hash password");
// let salt = Alphanumeric.sample_string(&mut rand::thread_rng(), 12);
let key = argon2::hash_raw(
password.as_ref(),
b"LUME_NEED_RUST_DEVELOPER_HELP_MAKE_SALT_RANDOM",
&config,
)
.expect("failed to hash password");
key.to_vec()
})

View File

@ -41,6 +41,9 @@
"$VIDEO/*"
]
},
"path": {
"all": true
},
"shell": {
"all": false,
"open": true

View File

@ -5,10 +5,13 @@ import { CreateStep1Screen } from '@app/auth/create/step-1';
import { CreateStep2Screen } from '@app/auth/create/step-2';
import { CreateStep3Screen } from '@app/auth/create/step-3';
import { CreateStep4Screen } from '@app/auth/create/step-4';
import { CreateStep5Screen } from '@app/auth/create/step-5';
import { AuthImportScreen } from '@app/auth/import';
import { ImportStep1Screen } from '@app/auth/import/step-1';
import { ImportStep2Screen } from '@app/auth/import/step-2';
import { ImportStep3Screen } from '@app/auth/import/step-3';
import { OnboardingScreen } from '@app/auth/onboarding';
import { UnlockScreen } from '@app/auth/unlock';
import { WelcomeScreen } from '@app/auth/welcome';
import { ChannelScreen } from '@app/channel';
import { ChatScreen } from '@app/chat';
@ -51,6 +54,7 @@ const router = createBrowserRouter([
children: [
{ path: '', element: <ImportStep1Screen /> },
{ path: 'step-2', element: <ImportStep2Screen /> },
{ path: 'step-3', element: <ImportStep3Screen /> },
],
},
{
@ -61,8 +65,10 @@ const router = createBrowserRouter([
{ path: 'step-2', element: <CreateStep2Screen /> },
{ path: 'step-3', element: <CreateStep3Screen /> },
{ path: 'step-4', element: <CreateStep4Screen /> },
{ path: 'step-5', element: <CreateStep5Screen /> },
],
},
{ path: 'unlock', element: <UnlockScreen /> },
],
},
{

View File

@ -8,11 +8,17 @@ import { createAccount } from '@libs/storage';
import { Button } from '@shared/button';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function CreateStep1Screen() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [type, setType] = useState('password');
const [setPubkey, setPrivkey] = useOnboarding((state) => [
state.setPubkey,
state.setPrivkey,
]);
const [privkeyInput, setPrivkeyInput] = useState('password');
const [loading, setLoading] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []);
@ -22,10 +28,10 @@ export function CreateStep1Screen() {
// toggle private key
const showPrivateKey = () => {
if (type === 'password') {
setType('text');
if (privkeyInput === 'password') {
setPrivkeyInput('text');
} else {
setType('password');
setPrivkeyInput('password');
}
};
@ -33,11 +39,10 @@ export function CreateStep1Screen() {
mutationFn: (data: {
npub: string;
pubkey: string;
privkey: string;
follows: null | string[][];
is_active: number;
}) => {
return createAccount(data.npub, data.pubkey, data.privkey, null, 1);
return createAccount(data.npub, data.pubkey, null, 1);
},
onSuccess: (data) => {
queryClient.setQueryData(['currentAccount'], data);
@ -47,10 +52,12 @@ export function CreateStep1Screen() {
const submit = () => {
setLoading(true);
setPubkey(pubkey);
setPrivkey(privkey);
account.mutate({
npub,
pubkey,
privkey,
follows: null,
is_active: 1,
});
@ -80,7 +87,7 @@ export function CreateStep1Screen() {
<div className="relative">
<input
readOnly
type={type}
type={privkeyInput}
value={nsec}
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
@ -89,7 +96,7 @@ export function CreateStep1Screen() {
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === 'password' ? (
{privkeyInput === 'password' ? (
<EyeOffIcon
width={20}
height={20}

View File

@ -1,138 +1,126 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader';
import { LoaderIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.password ? values : {},
errors: !values.password
? {
password: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function CreateStep2Screen() {
const navigate = useNavigate();
const createProfile = useOnboarding((state: any) => state.createProfile);
const setPassword = useStronghold((state) => state.setPassword);
const [picture, setPicture] = useState(DEFAULT_AVATAR);
const [banner, setBanner] = useState('');
const [pubkey, privkey] = useOnboarding((state) => [state.pubkey, state.privkey]);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { save } = useSecureStorage();
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
const {
register,
setError,
handleSubmit,
formState: { isDirty, isValid },
} = useForm();
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const onSubmit = (data: any) => {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
try {
const profile = {
...data,
username: data.name,
display_name: data.name,
bio: data.about,
};
createProfile(profile);
if (data.password.length > 3) {
// add password to local state
setPassword(data.password);
// save privkey to secure storage
await save(pubkey, privkey, data.password);
// redirect to next step
setTimeout(() => navigate('/auth/create/step-3', { replace: true }), 1200);
} catch {
console.log('error');
navigate('/auth/create/step-3', { replace: true });
} else {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Password is required and must be greater than 3',
});
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Create your profile</h1>
<h1 className="text-xl font-semibold text-zinc-100">
Set password to secure your key
</h1>
</div>
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input
type={'hidden'}
{...register('picture')}
value={picture}
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<input
type={'hidden'}
{...register('banner')}
value={banner}
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="relative">
<div className="relative h-44 w-full bg-zinc-800">
<Image
src={banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt="user's banner"
className="h-full w-full object-cover"
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<BannerUploader setBanner={setBanner} />
</div>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{passwordInput === 'password' ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
<div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14">
<Image
src={picture}
fallback={DEFAULT_AVATAR}
alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-zinc-900"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<AvatarUploader setPicture={setPicture} />
</div>
</div>
<div className="text-sm text-zinc-500">
<p>
Password is use to secure your key store in local machine, when you move
to other clients, you just need to copy your private key as nsec or
hexstring
</p>
</div>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
</div>
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Website
</label>
<input
type={'text'}
{...register('website', {
required: false,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}

View File

@ -1,98 +1,151 @@
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Body, fetch } from '@tauri-apps/api/http';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { Button } from '@shared/button';
import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader';
import { LoaderIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { useAccount } from '@utils/hooks/useAccount';
export function CreateStep3Screen() {
const profile = useOnboarding((state: any) => state.profile);
const navigate = useNavigate();
const createProfile = useOnboarding((state) => state.createProfile);
const { ndk } = useNDK();
const { account } = useAccount();
const [username, setUsername] = useState('');
const [picture, setPicture] = useState(DEFAULT_AVATAR);
const [banner, setBanner] = useState('');
const [loading, setLoading] = useState(false);
const createNIP05 = async () => {
const {
register,
handleSubmit,
formState: { isDirty, isValid },
} = useForm();
const onSubmit = (data: any) => {
setLoading(true);
try {
setLoading(true);
const response = await fetch('https://lume.nu/api/user-create', {
method: 'POST',
timeout: 30,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: Body.json({
username: username,
pubkey: account.pubkey,
lightningAddress: '',
}),
});
if (response.ok) {
const data = { ...profile, nip05: `${username}@lume.nu` };
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = JSON.stringify(data);
event.kind = 0;
event.pubkey = account.pubkey;
event.tags = [];
// publish event
event.publish();
// redirect to step 4
navigate('/auth/create/step-4', { replace: true });
}
} catch (error) {
setLoading(false);
console.error('Error:', error);
const profile = {
...data,
username: data.name,
display_name: data.name,
bio: data.about,
};
createProfile(profile);
// redirect to next step
setTimeout(() => navigate('/auth/create/step-4', { replace: true }), 1200);
} catch {
console.log('error');
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Create your Lume ID</h1>
<h1 className="text-xl font-semibold text-zinc-100">Create your profile</h1>
</div>
<div className="flex w-full flex-col items-center justify-center gap-4">
<div className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-800">
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoCapitalize="false"
autoCorrect="none"
spellCheck="false"
placeholder="satoshi"
className="relative w-full bg-transparent py-3 pl-3.5 text-zinc-100 !outline-none placeholder:text-zinc-500"
type={'hidden'}
{...register('picture')}
value={picture}
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<span className="pr-3.5 font-semibold text-fuchsia-500">@lume.nu</span>
</div>
<Button
preset="large"
onClick={() => createNIP05()}
disabled={username.length === 0}
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</Button>
<input
type={'hidden'}
{...register('banner')}
value={banner}
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="relative">
<div className="relative h-44 w-full bg-zinc-800">
<Image
src={banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt="user's banner"
className="h-full w-full object-cover"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<BannerUploader setBanner={setBanner} />
</div>
</div>
<div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14">
<Image
src={picture}
fallback={DEFAULT_AVATAR}
alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-zinc-900"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<AvatarUploader setPicture={setPicture} />
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Website
</label>
<input
type={'text'}
{...register('website', {
required: false,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</button>
</div>
</form>
</div>
</div>
);

View File

@ -1,235 +1,85 @@
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Body, fetch } from '@tauri-apps/api/http';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { Button } from '@shared/button';
import { LoaderIcon } from '@shared/icons';
import { useNDK } from '@libs/ndk/provider';
import { updateAccount } from '@libs/storage';
import { CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { RelayContext } from '@shared/relayProvider';
import { useOnboarding } from '@stores/onboarding';
import { useAccount } from '@utils/hooks/useAccount';
import { arrayToNIP02 } from '@utils/transform';
const INITIAL_LIST = [
{
pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2',
},
{
pubkey: 'a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98',
},
{
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
},
{
pubkey: 'c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0',
},
{
pubkey: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93',
},
{
pubkey: 'e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411',
},
{
pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d',
},
{
pubkey: 'c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15',
},
{
pubkey: 'e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42',
},
{
pubkey: '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240',
},
{
pubkey: '703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898',
},
{
pubkey: 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce',
},
{
pubkey: '4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0',
},
{
pubkey: 'c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965',
},
{
pubkey: 'c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6',
},
{
pubkey: '6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3',
},
{
pubkey: '50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63',
},
{
pubkey: '3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594',
},
{
pubkey: '6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c',
},
{
pubkey: '2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884',
},
{
pubkey: '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24',
},
{
pubkey: 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f',
},
{
pubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479',
},
{
pubkey: 'a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f',
},
{
pubkey: '1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b',
},
{
pubkey: 'c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5',
},
{
pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c',
},
{
pubkey: '7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a',
},
{
pubkey: 'b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27',
},
{
pubkey: 'e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2',
},
{
pubkey: 'ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14',
},
{
pubkey: 'ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609',
},
];
import { usePublish } from '@utils/hooks/usePublish';
export function CreateStep4Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const publish = usePublish();
const profile = useOnboarding((state) => state.profile);
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
const { ndk } = useNDK();
const { account } = useAccount();
const { status, data } = useQuery(['trending-profiles'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
}
return res.json();
});
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const update = useMutation({
mutationFn: (follows: any) => {
return updateAccount('follows', follows, account.pubkey);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
},
});
// save follows to database then broadcast
const submit = async () => {
const createNIP05 = async () => {
try {
setLoading(true);
const tags = arrayToNIP02([...follows, account.pubkey]);
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const response = await fetch('https://lume.nu/api/user-create', {
method: 'POST',
timeout: 30,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: Body.json({
username: username,
pubkey: account.pubkey,
lightningAddress: '',
}),
});
const event = new NDKEvent(ndk);
// build event
event.content = '';
event.kind = 3;
event.pubkey = account.pubkey;
event.tags = tags;
// publish event
event.publish();
if (response.ok) {
const data = { ...profile, nip05: `${username}@lume.nu` };
publish({ content: JSON.stringify(data), kind: 0, tags: [] });
// update
update.mutate([...follows, account.pubkey]);
// redirect to next step
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
} catch {
console.log('error');
// redirect to step 4
navigate('/auth/create/step-5', { replace: true });
}
} catch (error) {
setLoading(false);
console.error('Error:', error);
}
};
const list = data ? data.profiles.concat(INITIAL_LIST) : [];
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Personalized your newsfeed
</h1>
<h1 className="text-xl font-semibold text-zinc-100">Create your Lume ID</h1>
</div>
<div className="flex flex-col gap-4">
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
Follow at least
<span className="font-semibold text-fuchsia-500">
{follows.length}/10
</span>{' '}
plebs
</div>
{status === 'loading' ? (
<div className="inline-flex h-11 w-full items-center justify-center px-4 py-2">
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</div>
) : (
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
{list.map((item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
>
<User pubkey={item.pubkey} fallback={item.profile?.content} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon className="h-4 w-4 text-green-400" />
</div>
)}
</button>
))}
</div>
)}
<div className="flex w-full flex-col items-center justify-center gap-4">
<div className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-800">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoCapitalize="false"
autoCorrect="none"
spellCheck="false"
placeholder="satoshi"
className="relative w-full bg-transparent py-3 pl-3.5 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
<span className="pr-3.5 font-semibold text-fuchsia-500">@lume.nu</span>
</div>
{follows.length >= 10 && (
<button
type="button"
onClick={() => submit()}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Finish →'
)}
</button>
)}
<Button
preset="large"
onClick={() => createNIP05()}
disabled={username.length === 0}
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</Button>
</div>
</div>
);

View File

@ -0,0 +1,224 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { updateAccount } from '@libs/storage';
import { CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
import { usePublish } from '@utils/hooks/usePublish';
import { arrayToNIP02 } from '@utils/transform';
const INITIAL_LIST = [
{
pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2',
},
{
pubkey: 'a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98',
},
{
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
},
{
pubkey: 'c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0',
},
{
pubkey: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93',
},
{
pubkey: 'e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411',
},
{
pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d',
},
{
pubkey: 'c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15',
},
{
pubkey: 'e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42',
},
{
pubkey: '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240',
},
{
pubkey: '703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898',
},
{
pubkey: 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce',
},
{
pubkey: '4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0',
},
{
pubkey: 'c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965',
},
{
pubkey: 'c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6',
},
{
pubkey: '6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3',
},
{
pubkey: '50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63',
},
{
pubkey: '3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594',
},
{
pubkey: '6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c',
},
{
pubkey: '2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884',
},
{
pubkey: '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24',
},
{
pubkey: 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f',
},
{
pubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479',
},
{
pubkey: 'a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f',
},
{
pubkey: '1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b',
},
{
pubkey: 'c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5',
},
{
pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c',
},
{
pubkey: '7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a',
},
{
pubkey: 'b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27',
},
{
pubkey: 'e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2',
},
{
pubkey: 'ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14',
},
{
pubkey: 'ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609',
},
];
export function CreateStep5Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const publish = usePublish();
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
const { account } = useAccount();
const { status, data } = useQuery(['trending-profiles'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
}
return res.json();
});
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
const update = useMutation({
mutationFn: (follows: any) => {
return updateAccount('follows', follows, account.pubkey);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
},
});
// save follows to database then broadcast
const submit = async () => {
try {
setLoading(true);
const tags = arrayToNIP02([...follows, account.pubkey]);
publish({ content: '', kind: 3, tags: tags });
// update
update.mutate([...follows, account.pubkey]);
// redirect to next step
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
} catch {
console.log('error');
}
};
const list = data ? data.profiles.concat(INITIAL_LIST) : [];
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Personalized your newsfeed
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
Follow at least
<span className="font-semibold text-fuchsia-500">
{follows.length}/10
</span>{' '}
plebs
</div>
{status === 'loading' ? (
<div className="inline-flex h-11 w-full items-center justify-center px-4 py-2">
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</div>
) : (
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
{list.map((item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
>
<User pubkey={item.pubkey} fallback={item.profile?.content} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon className="h-4 w-4 text-green-400" />
</div>
)}
</button>
))}
</div>
)}
</div>
{follows.length >= 10 && (
<button
type="button"
onClick={() => submit()}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Finish →'
)}
</button>
)}
</div>
</div>
);
}

View File

@ -8,16 +8,18 @@ import { createAccount } from '@libs/storage';
import { LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
type FormValues = {
key: string;
privkey: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.key ? values : {},
errors: !values.key
values: values.privkey ? values : {},
errors: !values.privkey
? {
key: {
privkey: {
type: 'required',
message: 'This is required.',
},
@ -27,16 +29,25 @@ const resolver: Resolver<FormValues> = async (values) => {
};
export function ImportStep1Screen() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [setPubkey, setPrivkey] = useOnboarding((state) => [
state.setPubkey,
state.setPrivkey,
]);
const [loading, setLoading] = useState(false);
const account = useMutation({
mutationFn: (data: any) => {
return createAccount(data.npub, data.pubkey, data.privkey, null, 1);
mutationFn: (data: {
npub: string;
pubkey: string;
follows: null | string[];
is_active: number | boolean;
}) => {
return createAccount(data.npub, data.pubkey, null, 1);
},
onSuccess: (data: any) => {
onSuccess: (data) => {
queryClient.setQueryData(['currentAccount'], data);
},
});
@ -48,24 +59,27 @@ export function ImportStep1Screen() {
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: any) => {
const onSubmit = async (data: { [x: string]: string }) => {
try {
setLoading(true);
let privkey = data['key'];
let privkey = data['privkey'];
if (privkey.substring(0, 4) === 'nsec') {
privkey = nip19.decode(privkey).data;
privkey = nip19.decode(privkey).data as string;
}
if (typeof getPublicKey(privkey) === 'string') {
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
// update
// use for onboarding process only
setPubkey(pubkey);
setPrivkey(privkey);
// add account to local database
account.mutate({
npub,
pubkey,
privkey,
follows: null,
is_active: 1,
});
@ -74,7 +88,7 @@ export function ImportStep1Screen() {
setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
}
} catch (error) {
setError('key', {
setError('privkey', {
type: 'custom',
message: 'Private Key is invalid, please check again',
});
@ -88,15 +102,16 @@ export function ImportStep1Screen() {
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-0.5">
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-zinc-400">Private key</span>
<input
{...register('key', { required: true, minLength: 32 })}
{...register('privkey', { required: true, minLength: 32 })}
type={'password'}
placeholder="Paste private key here..."
placeholder="nsec or hexstring"
className="relative w-full rounded-lg bg-zinc-800 px-3 py-3 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
<span className="text-base text-red-400">
{errors.key && <p>{errors.key.message}</p>}
<span className="text-sm text-red-400">
{errors.privkey && <p>{errors.privkey.message}</p>}
</span>
</div>
<div className="flex items-center justify-center">

View File

@ -1,87 +1,139 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useNDK } from '@libs/ndk/provider';
import { updateAccount } from '@libs/storage';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
import { Button } from '@shared/button';
import { LoaderIcon } from '@shared/icons';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
import { useAccount } from '@utils/hooks/useAccount';
import { setToArray } from '@utils/transform';
type FormValues = {
password: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.password ? values : {},
errors: !values.password
? {
password: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function ImportStep2Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setPassword = useStronghold((state) => state.setPassword);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const [pubkey, privkey] = useOnboarding((state) => [state.pubkey, state.privkey]);
const { ndk } = useNDK();
const { status, account } = useAccount();
const { save } = useSecureStorage();
const update = useMutation({
mutationFn: (follows: any) => {
return updateAccount('follows', follows, account.pubkey);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
},
});
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
const submit = async () => {
try {
// show loading indicator
setLoading(true);
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const user = ndk.getUser({ hexpubkey: account.pubkey });
const follows = await user.follows();
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
// add password to local state
setPassword(data.password);
// follows as list
const followsList = setToArray(follows);
// update
update.mutate([...followsList, account.pubkey]);
// save privkey to secure storage
await save(pubkey, privkey);
// redirect to next step
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
} catch {
console.log('error');
navigate('/auth/import/step-3', { replace: true });
} else {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Password is required and must be greater than 3, please check again',
});
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold">
{loading ? 'Creating...' : 'Continue with'}
<h1 className="text-xl font-semibold text-zinc-100">
Set password to secure your key
</h1>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
{status === 'loading' ? (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
<div>
<div className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{passwordInput === 'password' ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
<div className="text-sm text-zinc-500">
<p>
Password is use to secure your key store in local machine, when you move
to other clients, you just need to copy your private key as nsec or
hexstring
</p>
</div>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
</div>
) : (
<div className="flex flex-col gap-3">
<User pubkey={account.pubkey} />
<Button preset="large" onClick={() => submit()}>
<div className="flex items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</Button>
</button>
</div>
)}
</form>
</div>
</div>
);

View File

@ -0,0 +1,88 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useNDK } from '@libs/ndk/provider';
import { updateAccount } from '@libs/storage';
import { Button } from '@shared/button';
import { LoaderIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
import { setToArray } from '@utils/transform';
export function ImportStep3Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const { ndk } = useNDK();
const { status, account } = useAccount();
const update = useMutation({
mutationFn: (follows: string[]) => {
return updateAccount('follows', follows, account.pubkey);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
},
});
const submit = async () => {
try {
// show loading indicator
setLoading(true);
const user = ndk.getUser({ hexpubkey: account.pubkey });
const follows = await user.follows();
// follows as list
const followsList = setToArray(follows);
// update
update.mutate([...followsList, account.pubkey]);
// redirect to next step
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
} catch {
console.log('error');
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold">
{loading ? 'Creating...' : 'Continue with'}
</h1>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
{status === 'loading' ? (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
<div>
<div className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<User pubkey={account.pubkey} />
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</Button>
</div>
)}
</div>
</div>
);
}

154
src/app/auth/unlock.tsx Normal file
View File

@ -0,0 +1,154 @@
import { getPublicKey } from 'nostr-tools';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.password ? values : {},
errors: !values.password
? {
password: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function UnlockScreen() {
const navigate = useNavigate();
const setPassword = useStronghold((state) => state.setPassword);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { account } = useAccount();
const { load } = useSecureStorage();
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
// add password to local state
setPassword(data.password);
// load private in secure storage
const privkey = await load(account.pubkey, data.password);
if (!privkey) {
setLoading(false);
setError('password', {
type: 'custom',
message: "Can't get private key",
});
}
const tempPubkey = getPublicKey(privkey);
if (tempPubkey === account.pubkey) {
// redirect to next step
navigate('/', { replace: true });
} else {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Wrong password',
});
}
} else {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Password is required and must be greater than 3',
});
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Enter password to unlock
</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{passwordInput === 'password' ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
</div>
<div className="flex items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@ -38,14 +38,13 @@ export async function getAccounts() {
export async function createAccount(
npub: string,
pubkey: string,
privkey: string,
follows?: string[][],
is_active?: number
) {
const db = await connect();
const res = await db.execute(
'INSERT OR IGNORE INTO accounts (npub, pubkey, privkey, follows, is_active) VALUES (?, ?, ?, ?, ?);',
[npub, pubkey, privkey, follows || '', is_active || 0]
[npub, pubkey, 'privkey is stored in secure storage', follows || '', is_active || 0]
);
if (res) {
await createBlock(

View File

@ -1,14 +1,21 @@
import { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
export function Protected({ children }: { children: ReactNode }) {
const password = useStronghold((state) => state.password);
const { status, account } = useAccount();
if (status === 'success' && !account) {
return <Navigate to="/auth/welcome" replace />;
}
if (status === 'success' && account && !password) {
return <Navigate to="/auth/unlock" replace />;
}
return children;
}

View File

@ -1,17 +1,29 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
export const useOnboarding = create(
persist(
(set) => ({
profile: {},
createProfile: (data) => {
set({ profile: data });
},
}),
{
name: 'onboarding',
storage: createJSONStorage(() => sessionStorage),
}
)
);
interface OnboardingState {
profile: { [x: string]: string };
pubkey: string;
privkey: string;
createProfile: (data: { [x: string]: string }) => void;
setPubkey: (pubkey: string) => void;
setPrivkey: (privkey: string) => void;
clearPrivkey: (privkey: string) => void;
}
export const useOnboarding = create<OnboardingState>((set) => ({
profile: {},
pubkey: '',
privkey: '',
createProfile: (data: { [x: string]: string }) => {
set({ profile: data });
},
setPubkey: (pubkey: string) => {
set({ pubkey: pubkey });
},
setPrivkey: (privkey: string) => {
set({ privkey: privkey });
},
clearPrivkey: () => {
set({ privkey: '' });
},
}));

13
src/stores/stronghold.tsx Normal file
View File

@ -0,0 +1,13 @@
import { create } from 'zustand';
interface StrongholdState {
password: null | string;
setPassword: (password: string) => void;
}
export const useStronghold = create<StrongholdState>((set) => ({
password: null,
setPassword: (password: string) => {
set({ password: password });
},
}));

View File

@ -3,10 +3,12 @@ import { NDKEvent, NDKKind, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useNDK } from '@libs/ndk/provider';
import { useAccount } from '@utils/hooks/useAccount';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
export function usePublish() {
const { ndk } = useNDK();
const { account } = useAccount();
const { load } = useSecureStorage();
const publish = async ({
content,
@ -17,8 +19,10 @@ export function usePublish() {
kind: NDKKind;
tags: string[][];
}): Promise<NDKEvent> => {
const privkey = await load(account.pubkey);
const event = new NDKEvent(ndk);
const signer = new NDKPrivateKeySigner(account.privkey);
const signer = new NDKPrivateKeySigner(privkey);
event.content = content;
event.kind = kind;

View File

@ -0,0 +1,43 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStronghold } from '@stores/stronghold';
const dir = await appConfigDir();
export function useSecureStorage() {
const password = useStronghold((state) => state.password);
async function getClient(stronghold: Stronghold) {
try {
return await stronghold.loadClient('lume');
} catch {
return await stronghold.createClient('lume');
}
}
const save = async (key: string, value: string, userpass?: string) => {
const stronghold = await Stronghold.load(
`${dir}lume.stronghold`,
userpass ? userpass : password
);
const client = await getClient(stronghold);
const store = client.getStore();
await store.insert(key, Array.from(new TextEncoder().encode(value)));
return await stronghold.save();
};
const load = async (key: string, userpass?: string) => {
const stronghold = await Stronghold.load(
`${dir}lume.stronghold`,
userpass ? userpass : password
);
const client = await getClient(stronghold);
const store = client.getStore();
const value = await store.get(key);
const decoded = new TextDecoder().decode(new Uint8Array(value));
return decoded;
};
return { save, load };
}