feat: add some improvements from lume

This commit is contained in:
2024-09-01 11:51:11 +07:00
parent aa61e4daec
commit 529a410fe9
13 changed files with 420 additions and 169 deletions

View File

@@ -1,7 +1,8 @@
use keyring::Entry; use keyring::Entry;
use keyring_search::{Limit, List, Search}; use keyring_search::{Limit, List, Search};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Serialize; use serde::{Deserialize, Serialize};
use specta::Type;
use std::{collections::HashSet, str::FromStr, time::Duration}; use std::{collections::HashSet, str::FromStr, time::Duration};
use tauri::{Emitter, Manager, State}; use tauri::{Emitter, Manager, State};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
@@ -14,18 +15,6 @@ pub struct EventPayload {
sender: String, sender: String,
} }
#[tauri::command]
#[specta::specta]
pub fn get_accounts() -> Vec<String> {
let search = Search::new().expect("Unexpected.");
let results = search.by_service("Coop Secret Storage");
let list = List::list_credentials(&results, Limit::All);
let accounts: HashSet<String> =
list.split_whitespace().filter(|v| v.starts_with("npub1")).map(String::from).collect();
accounts.into_iter().collect()
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_metadata(user_id: String, state: State<'_, Nostr>) -> Result<String, String> { pub async fn get_metadata(user_id: String, state: State<'_, Nostr>) -> Result<String, String> {
@@ -48,13 +37,22 @@ pub async fn get_metadata(user_id: String, state: State<'_, Nostr>) -> Result<St
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
struct Account {
password: String,
nostr_connect: Option<String>,
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn delete_account(id: String) -> Result<(), String> { pub fn get_accounts() -> Vec<String> {
let keyring = Entry::new("Coop Secret Storage", &id).map_err(|e| e.to_string())?; let search = Search::new().expect("Unexpected.");
let _ = keyring.delete_credential(); let results = search.by_service("Coop Secret Storage");
let list = List::list_credentials(&results, Limit::All);
let accounts: HashSet<String> =
list.split_whitespace().filter(|v| v.starts_with("npub1")).map(String::from).collect();
Ok(()) accounts.into_iter().collect()
} }
#[tauri::command] #[tauri::command]
@@ -75,8 +73,10 @@ pub async fn create_account(
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?; let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
// Save account // Save account
let keyring = Entry::new("Coop Secret Storage", &npub).unwrap(); let keyring = Entry::new("Coop Secret Storage", &npub).map_err(|e| e.to_string())?;
let _ = keyring.set_password(&enc_bech32); let account = Account { password: enc_bech32, nostr_connect: None };
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
let _ = keyring.set_password(&j);
let signer = NostrSigner::Keys(keys); let signer = NostrSigner::Keys(keys);
@@ -98,44 +98,49 @@ pub async fn create_account(
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn import_key( pub async fn import_account(key: String, password: String) -> Result<String, String> {
key: String, let (npub, enc_bech32) = match key.starts_with("ncryptsec") {
password: Option<String>, true => {
state: State<'_, Nostr>, let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
) -> Result<String, String> { let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
let client = &state.client; let secret_key = enc.to_secret_key(password).map_err(|err| err.to_string())?;
let keys = Keys::new(secret_key);
let npub = keys.public_key().to_bech32().unwrap();
(npub, enc_bech32)
}
false => {
let secret_key = SecretKey::from_bech32(key).map_err(|err| err.to_string())?; let secret_key = SecretKey::from_bech32(key).map_err(|err| err.to_string())?;
let keys = Keys::new(secret_key.clone()); let keys = Keys::new(secret_key.clone());
let npub = keys.public_key().to_bech32().unwrap(); let npub = keys.public_key().to_bech32().unwrap();
let enc_bech32 = match password { let enc = EncryptedSecretKey::new(&secret_key, password, 16, KeySecurity::Medium)
Some(pw) => {
let enc = EncryptedSecretKey::new(&secret_key, pw, 16, KeySecurity::Medium)
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
enc.to_bech32().map_err(|err| err.to_string())? let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
(npub, enc_bech32)
} }
None => secret_key.to_bech32().map_err(|err| err.to_string())?,
}; };
let keyring = Entry::new("Coop Secret Storage", &npub).unwrap(); let keyring = Entry::new("Coop Secret Storage", &npub).map_err(|e| e.to_string())?;
let _ = keyring.set_password(&enc_bech32);
let signer = NostrSigner::Keys(keys); let account = Account { password: enc_bech32, nostr_connect: None };
// Update client's signer let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
client.set_signer(Some(signer)).await; keyring.set_password(&pwd).map_err(|e| e.to_string())?;
Ok(npub) Ok(npub)
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn connect_account(uri: &str, state: State<'_, Nostr>) -> Result<String, String> { pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client; let client = &state.client;
match NostrConnectURI::parse(uri) { match NostrConnectURI::parse(uri.clone()) {
Ok(bunker_uri) => { Ok(bunker_uri) => {
// Local user
let app_keys = Keys::generate(); let app_keys = Keys::generate();
let app_secret = app_keys.secret_key().to_string(); let app_secret = app_keys.secret_key().to_string();
@@ -145,8 +150,20 @@ pub async fn connect_account(uri: &str, state: State<'_, Nostr>) -> Result<Strin
match Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(120), None).await { match Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(120), None).await {
Ok(signer) => { Ok(signer) => {
let keyring = Entry::new("Coop Secret Storage", &remote_npub).unwrap(); let mut url = Url::parse(&uri).unwrap();
let _ = keyring.set_password(&app_secret); let query: Vec<(String, String)> = url
.query_pairs()
.filter(|(name, _)| name != "secret")
.map(|(name, value)| (name.into_owned(), value.into_owned()))
.collect();
url.query_pairs_mut().clear().extend_pairs(&query);
let key = format!("{}_nostrconnect", remote_npub);
let keyring = Entry::new("Coop Secret Storage", &key).unwrap();
let account =
Account { password: app_secret, nostr_connect: Some(url.to_string()) };
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
let _ = keyring.set_password(&j);
// Update signer // Update signer
let _ = client.set_signer(Some(signer.into())).await; let _ = client.set_signer(Some(signer.into())).await;
@@ -160,6 +177,34 @@ pub async fn connect_account(uri: &str, state: State<'_, Nostr>) -> Result<Strin
} }
} }
#[tauri::command]
#[specta::specta]
pub async fn reset_password(key: String, password: String) -> Result<(), String> {
let secret_key = SecretKey::from_bech32(key).map_err(|err| err.to_string())?;
let keys = Keys::new(secret_key.clone());
let npub = keys.public_key().to_bech32().unwrap();
let enc = EncryptedSecretKey::new(&secret_key, password, 16, KeySecurity::Medium)
.map_err(|err| err.to_string())?;
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
let keyring = Entry::new("Coop Secret Storage", &npub).map_err(|e| e.to_string())?;
let account = Account { password: enc_bech32, nostr_connect: None };
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
let _ = keyring.set_password(&j);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn delete_account(id: String) -> Result<(), String> {
let keyring = Entry::new("Coop Secret Storage", &id).map_err(|e| e.to_string())?;
let _ = keyring.delete_credential();
Ok(())
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> { pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
@@ -185,12 +230,18 @@ pub async fn login(
let client = &state.client; let client = &state.client;
let keyring = Entry::new("Coop Secret Storage", &account).map_err(|e| e.to_string())?; let keyring = Entry::new("Coop Secret Storage", &account).map_err(|e| e.to_string())?;
let bech32 = match keyring.get_password() { let account = match keyring.get_password() {
Ok(pw) => pw, Ok(pw) => {
Err(_) => return Err("Action have been cancelled".into()), let account: Account = serde_json::from_str(&pw).map_err(|e| e.to_string())?;
account
}
Err(e) => return Err(e.to_string()),
}; };
let ncryptsec = EncryptedSecretKey::from_bech32(bech32).map_err(|e| e.to_string())?; let public_key = match account.nostr_connect {
None => {
let ncryptsec =
EncryptedSecretKey::from_bech32(account.password).map_err(|e| e.to_string())?;
let secret_key = ncryptsec.to_secret_key(password).map_err(|_| "Wrong password.")?; let secret_key = ncryptsec.to_secret_key(password).map_err(|_| "Wrong password.")?;
let keys = Keys::new(secret_key); let keys = Keys::new(secret_key);
let public_key = keys.public_key(); let public_key = keys.public_key();
@@ -199,6 +250,24 @@ pub async fn login(
// Update signer // Update signer
client.set_signer(Some(signer)).await; client.set_signer(Some(signer)).await;
public_key
}
Some(bunker) => {
let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?;
let public_key = uri.signer_public_key().unwrap();
let app_keys = Keys::from_str(&account.password).map_err(|e| e.to_string())?;
match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None).await {
Ok(signer) => {
// Update signer
client.set_signer(Some(signer.into())).await;
public_key
}
Err(e) => return Err(e.to_string()),
}
}
};
let inbox = Filter::new().kind(Kind::Custom(10050)).author(public_key).limit(1); let inbox = Filter::new().kind(Kind::Custom(10050)).author(public_key).limit(1);
if let Ok(events) = if let Ok(events) =

View File

@@ -40,10 +40,11 @@ fn main() {
connect_inbox_relays, connect_inbox_relays,
disconnect_inbox_relays, disconnect_inbox_relays,
login, login,
delete_account,
create_account, create_account,
import_key, import_account,
connect_account, connect_account,
delete_account,
reset_password,
get_accounts, get_accounts,
get_metadata, get_metadata,
get_contact_list, get_contact_list,

View File

@@ -61,14 +61,6 @@ async login(account: string, password: string) : Promise<Result<string, string>>
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async deleteAccount(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("delete_account", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async createAccount(name: string, about: string, picture: string, password: string) : Promise<Result<string, string>> { async createAccount(name: string, about: string, picture: string, password: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("create_account", { name, about, picture, password }) }; return { status: "ok", data: await TAURI_INVOKE("create_account", { name, about, picture, password }) };
@@ -77,9 +69,9 @@ async createAccount(name: string, about: string, picture: string, password: stri
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async importKey(key: string, password: string | null) : Promise<Result<string, string>> { async importAccount(key: string, password: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("import_key", { key, password }) }; return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -93,6 +85,22 @@ async connectAccount(uri: string) : Promise<Result<string, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async deleteAccount(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("delete_account", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async resetPassword(key: string, password: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reset_password", { key, password }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAccounts() : Promise<string[]> { async getAccounts() : Promise<string[]> {
return await TAURI_INVOKE("get_accounts"); return await TAURI_INVOKE("get_accounts");
}, },

5
src/components/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from "./frame";
export * from "./back";
export * from "./spinner";
export * from "./user";

View File

@@ -15,43 +15,32 @@ import { createFileRoute } from '@tanstack/react-router'
import { Route as rootRoute } from './routes/__root' import { Route as rootRoute } from './routes/__root'
import { Route as BootstrapRelaysImport } from './routes/bootstrap-relays' import { Route as BootstrapRelaysImport } from './routes/bootstrap-relays'
import { Route as IndexImport } from './routes/index' import { Route as IndexImport } from './routes/index'
import { Route as AuthNewImport } from './routes/auth/new'
import { Route as AuthImportImport } from './routes/auth/import'
import { Route as AuthConnectImport } from './routes/auth/connect'
import { Route as AccountRelaysImport } from './routes/$account.relays' import { Route as AccountRelaysImport } from './routes/$account.relays'
import { Route as AccountContactsImport } from './routes/$account.contacts' import { Route as AccountContactsImport } from './routes/$account.contacts'
import { Route as AccountChatsIdImport } from './routes/$account.chats.$id' import { Route as AccountChatsIdImport } from './routes/$account.chats.$id'
// Create Virtual Routes // Create Virtual Routes
const NostrConnectLazyImport = createFileRoute('/nostr-connect')() const ResetLazyImport = createFileRoute('/reset')()
const NewLazyImport = createFileRoute('/new')() const NewLazyImport = createFileRoute('/new')()
const ImportKeyLazyImport = createFileRoute('/import-key')()
const CreateAccountLazyImport = createFileRoute('/create-account')()
const AccountChatsLazyImport = createFileRoute('/$account/chats')() const AccountChatsLazyImport = createFileRoute('/$account/chats')()
const AccountChatsNewLazyImport = createFileRoute('/$account/chats/new')() const AccountChatsNewLazyImport = createFileRoute('/$account/chats/new')()
// Create/Update Routes // Create/Update Routes
const NostrConnectLazyRoute = NostrConnectLazyImport.update({ const ResetLazyRoute = ResetLazyImport.update({
path: '/nostr-connect', path: '/reset',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/nostr-connect.lazy').then((d) => d.Route)) } as any).lazy(() => import('./routes/reset.lazy').then((d) => d.Route))
const NewLazyRoute = NewLazyImport.update({ const NewLazyRoute = NewLazyImport.update({
path: '/new', path: '/new',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/new.lazy').then((d) => d.Route)) } as any).lazy(() => import('./routes/new.lazy').then((d) => d.Route))
const ImportKeyLazyRoute = ImportKeyLazyImport.update({
path: '/import-key',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/import-key.lazy').then((d) => d.Route))
const CreateAccountLazyRoute = CreateAccountLazyImport.update({
path: '/create-account',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/create-account.lazy').then((d) => d.Route),
)
const BootstrapRelaysRoute = BootstrapRelaysImport.update({ const BootstrapRelaysRoute = BootstrapRelaysImport.update({
path: '/bootstrap-relays', path: '/bootstrap-relays',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@@ -71,6 +60,21 @@ const AccountChatsLazyRoute = AccountChatsLazyImport.update({
import('./routes/$account.chats.lazy').then((d) => d.Route), import('./routes/$account.chats.lazy').then((d) => d.Route),
) )
const AuthNewRoute = AuthNewImport.update({
path: '/auth/new',
getParentRoute: () => rootRoute,
} as any)
const AuthImportRoute = AuthImportImport.update({
path: '/auth/import',
getParentRoute: () => rootRoute,
} as any)
const AuthConnectRoute = AuthConnectImport.update({
path: '/auth/connect',
getParentRoute: () => rootRoute,
} as any)
const AccountRelaysRoute = AccountRelaysImport.update({ const AccountRelaysRoute = AccountRelaysImport.update({
path: '/$account/relays', path: '/$account/relays',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@@ -117,20 +121,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BootstrapRelaysImport preLoaderRoute: typeof BootstrapRelaysImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/create-account': {
id: '/create-account'
path: '/create-account'
fullPath: '/create-account'
preLoaderRoute: typeof CreateAccountLazyImport
parentRoute: typeof rootRoute
}
'/import-key': {
id: '/import-key'
path: '/import-key'
fullPath: '/import-key'
preLoaderRoute: typeof ImportKeyLazyImport
parentRoute: typeof rootRoute
}
'/new': { '/new': {
id: '/new' id: '/new'
path: '/new' path: '/new'
@@ -138,11 +128,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof NewLazyImport preLoaderRoute: typeof NewLazyImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/nostr-connect': { '/reset': {
id: '/nostr-connect' id: '/reset'
path: '/nostr-connect' path: '/reset'
fullPath: '/nostr-connect' fullPath: '/reset'
preLoaderRoute: typeof NostrConnectLazyImport preLoaderRoute: typeof ResetLazyImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/$account/contacts': { '/$account/contacts': {
@@ -159,6 +149,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AccountRelaysImport preLoaderRoute: typeof AccountRelaysImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/auth/connect': {
id: '/auth/connect'
path: '/auth/connect'
fullPath: '/auth/connect'
preLoaderRoute: typeof AuthConnectImport
parentRoute: typeof rootRoute
}
'/auth/import': {
id: '/auth/import'
path: '/auth/import'
fullPath: '/auth/import'
preLoaderRoute: typeof AuthImportImport
parentRoute: typeof rootRoute
}
'/auth/new': {
id: '/auth/new'
path: '/auth/new'
fullPath: '/auth/new'
preLoaderRoute: typeof AuthNewImport
parentRoute: typeof rootRoute
}
'/$account/chats': { '/$account/chats': {
id: '/$account/chats' id: '/$account/chats'
path: '/$account/chats' path: '/$account/chats'
@@ -188,12 +199,13 @@ declare module '@tanstack/react-router' {
export const routeTree = rootRoute.addChildren({ export const routeTree = rootRoute.addChildren({
IndexRoute, IndexRoute,
BootstrapRelaysRoute, BootstrapRelaysRoute,
CreateAccountLazyRoute,
ImportKeyLazyRoute,
NewLazyRoute, NewLazyRoute,
NostrConnectLazyRoute, ResetLazyRoute,
AccountContactsRoute, AccountContactsRoute,
AccountRelaysRoute, AccountRelaysRoute,
AuthConnectRoute,
AuthImportRoute,
AuthNewRoute,
AccountChatsLazyRoute: AccountChatsLazyRoute.addChildren({ AccountChatsLazyRoute: AccountChatsLazyRoute.addChildren({
AccountChatsIdRoute, AccountChatsIdRoute,
AccountChatsNewLazyRoute, AccountChatsNewLazyRoute,
@@ -210,12 +222,13 @@ export const routeTree = rootRoute.addChildren({
"children": [ "children": [
"/", "/",
"/bootstrap-relays", "/bootstrap-relays",
"/create-account",
"/import-key",
"/new", "/new",
"/nostr-connect", "/reset",
"/$account/contacts", "/$account/contacts",
"/$account/relays", "/$account/relays",
"/auth/connect",
"/auth/import",
"/auth/new",
"/$account/chats" "/$account/chats"
] ]
}, },
@@ -225,17 +238,11 @@ export const routeTree = rootRoute.addChildren({
"/bootstrap-relays": { "/bootstrap-relays": {
"filePath": "bootstrap-relays.tsx" "filePath": "bootstrap-relays.tsx"
}, },
"/create-account": {
"filePath": "create-account.lazy.tsx"
},
"/import-key": {
"filePath": "import-key.lazy.tsx"
},
"/new": { "/new": {
"filePath": "new.lazy.tsx" "filePath": "new.lazy.tsx"
}, },
"/nostr-connect": { "/reset": {
"filePath": "nostr-connect.lazy.tsx" "filePath": "reset.lazy.tsx"
}, },
"/$account/contacts": { "/$account/contacts": {
"filePath": "$account.contacts.tsx" "filePath": "$account.contacts.tsx"
@@ -243,6 +250,15 @@ export const routeTree = rootRoute.addChildren({
"/$account/relays": { "/$account/relays": {
"filePath": "$account.relays.tsx" "filePath": "$account.relays.tsx"
}, },
"/auth/connect": {
"filePath": "auth/connect.tsx"
},
"/auth/import": {
"filePath": "auth/import.tsx"
},
"/auth/new": {
"filePath": "auth/new.tsx"
},
"/$account/chats": { "/$account/chats": {
"filePath": "$account.chats.lazy.tsx", "filePath": "$account.chats.lazy.tsx",
"children": [ "children": [

View File

@@ -2,12 +2,12 @@ import { commands } from "@/commands";
import { GoBack } from "@/components/back"; import { GoBack } from "@/components/back";
import { Frame } from "@/components/frame"; import { Frame } from "@/components/frame";
import { Spinner } from "@/components/spinner"; import { Spinner } from "@/components/spinner";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { readText } from "@tauri-apps/plugin-clipboard-manager"; import { readText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
export const Route = createLazyFileRoute("/nostr-connect")({ export const Route = createFileRoute("/auth/connect")({
component: Screen, component: Screen,
}); });
@@ -70,12 +70,12 @@ function Screen() {
placeholder="bunker://..." placeholder="bunker://..."
value={uri} value={uri}
onChange={(e) => setUri(e.target.value)} onChange={(e) => setUri(e.target.value)}
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none" className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/> />
<button <button
type="button" type="button"
onClick={() => pasteFromClipboard()} onClick={() => pasteFromClipboard()}
className="absolute top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500" className="absolute top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500 dark:text-blue-300"
> >
Paste Paste
</button> </button>

View File

@@ -2,12 +2,12 @@ import { commands } from "@/commands";
import { GoBack } from "@/components/back"; import { GoBack } from "@/components/back";
import { Frame } from "@/components/frame"; import { Frame } from "@/components/frame";
import { Spinner } from "@/components/spinner"; import { Spinner } from "@/components/spinner";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { readText } from "@tauri-apps/plugin-clipboard-manager"; import { readText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
export const Route = createLazyFileRoute("/import-key")({ export const Route = createFileRoute("/auth/import")({
component: Screen, component: Screen,
}); });
@@ -41,7 +41,7 @@ function Screen() {
return; return;
} }
const res = await commands.importKey(key, password); const res = await commands.importAccount(key, password);
if (res.status === "ok") { if (res.status === "ok") {
navigate({ to: "/", replace: true }); navigate({ to: "/", replace: true });
@@ -63,7 +63,7 @@ function Screen() {
<div className="w-[320px] flex flex-col gap-8"> <div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center"> <div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold"> <h1 className="leading-tight text-xl font-semibold">
Import Private Key Import Account
</h1> </h1>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -85,24 +85,26 @@ function Screen() {
placeholder="nsec or ncryptsec..." placeholder="nsec or ncryptsec..."
value={key} value={key}
onChange={(e) => setKey(e.target.value)} onChange={(e) => setKey(e.target.value)}
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600" className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/> />
<button <button
type="button" type="button"
onClick={() => pasteFromClipboard()} onClick={() => pasteFromClipboard()}
className="absolute uppercase top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500" className="absolute top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500 dark:text-blue-300"
> >
Paste Paste
</button> </button>
</div> </div>
</div> </div>
{key.length && !key.startsWith("ncryptsec") ? ( {key.length ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label
htmlFor="password" htmlFor="password"
className="text-sm font-medium text-neutral-800 dark:text-neutral-200" className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
> >
Set password to secure your key {!key.startsWith("ncryptsec")
? "Set password to secure your key"
: "Enter password to decrypt your key"}
</label> </label>
<input <input
name="password" name="password"

View File

@@ -4,11 +4,11 @@ import { GoBack } from "@/components/back";
import { Frame } from "@/components/frame"; import { Frame } from "@/components/frame";
import { Spinner } from "@/components/spinner"; import { Spinner } from "@/components/spinner";
import { Plus } from "@phosphor-icons/react"; import { Plus } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
export const Route = createLazyFileRoute("/create-account")({ export const Route = createFileRoute("/auth/new")({
component: Screen, component: Screen,
}); });
@@ -53,8 +53,7 @@ function Screen() {
if (res.status === "ok") { if (res.status === "ok") {
navigate({ navigate({
to: "/$account/relays", to: "/",
params: { account: res.data },
replace: true, replace: true,
}); });
} else { } else {
@@ -81,7 +80,7 @@ function Screen() {
className="flex flex-col gap-3 p-3 rounded-xl overflow-hidden" className="flex flex-col gap-3 p-3 rounded-xl overflow-hidden"
shadow shadow
> >
<div className="self-center relative rounded-full size-20 bg-neutral-100 dark:bg-neutral-900 my-3"> <div className="self-center relative rounded-full size-20 bg-neutral-100 dark:bg-white/10 my-3">
{picture.length ? ( {picture.length ? (
<img <img
src={picture} src={picture}
@@ -113,7 +112,7 @@ function Screen() {
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Alice" placeholder="e.g. Alice"
spellCheck={false} spellCheck={false}
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-600" className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -129,7 +128,7 @@ function Screen() {
onChange={(e) => setAbout(e.target.value)} onChange={(e) => setAbout(e.target.value)}
placeholder="e.g. Artist, anime-lover, and k-pop fan" placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false} spellCheck={false}
className="px-3 py-1.5 rounded-lg min-h-16 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-600" className="px-3 py-1.5 rounded-lg min-h-16 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
/> />
</div> </div>
<div className="h-px w-full mt-2 bg-neutral-100 dark:bg-neutral-900" /> <div className="h-px w-full mt-2 bg-neutral-100 dark:bg-neutral-900" />
@@ -145,7 +144,7 @@ function Screen() {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-600" className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
/> />
</div> </div>
</Frame> </Frame>

View File

@@ -1,9 +1,11 @@
import { commands } from "@/commands"; import { commands } from "@/commands";
import { GoBack } from "@/components";
import { Frame } from "@/components/frame"; import { Frame } from "@/components/frame";
import { Spinner } from "@/components/spinner"; import { Spinner } from "@/components/spinner";
import { Plus, X } from "@phosphor-icons/react"; import { ArrowLeft, Plus, X } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { relaunch } from "@tauri-apps/plugin-process";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
export const Route = createLazyFileRoute("/bootstrap-relays")({ export const Route = createLazyFileRoute("/bootstrap-relays")({
@@ -50,13 +52,11 @@ function Screen() {
return; return;
} }
const merged = relays const merged = relays.join("\r\n");
.map((relay) => Object.values(relay).join(","))
.join("\n");
const res = await commands.setBootstrapRelays(merged); const res = await commands.setBootstrapRelays(merged);
if (res.status === "ok") { if (res.status === "ok") {
// TODO: restart app return await relaunch();
} else { } else {
await message(res.error, { await message(res.error, {
title: "Manage Relays", title: "Manage Relays",
@@ -72,7 +72,10 @@ function Screen() {
}, [bootstrapRelays]); }, [bootstrapRelays]);
return ( return (
<div className="size-full flex items-center justify-center"> <div
data-tauri-drag-region
className="relative size-full flex items-center justify-center"
>
<div className="w-[320px] flex flex-col gap-8"> <div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center"> <div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold">Manage Relays</h1> <h1 className="leading-tight text-xl font-semibold">Manage Relays</h1>
@@ -134,9 +137,16 @@ function Screen() {
> >
{isPending ? <Spinner /> : "Save & Restart"} {isPending ? <Spinner /> : "Save & Restart"}
</button> </button>
<span className="mt-2 w-full text-sm text-neutral-600 dark:text-neutral-400 inline-flex items-center justify-center">
Lume will relaunch after saving.
</span>
</div> </div>
</div> </div>
</div> </div>
<GoBack className="fixed top-11 left-2 flex items-center gap-1.5 text-sm font-medium">
<ArrowLeft className="size-5" />
Back
</GoBack>
</div> </div>
); );
} }

View File

@@ -1,8 +1,6 @@
import { commands } from "@/commands"; import { commands } from "@/commands";
import { npub } from "@/commons"; import { npub } from "@/commons";
import { Frame } from "@/components/frame"; import { Frame, User, Spinner } from "@/components";
import { Spinner } from "@/components/spinner";
import { User } from "@/components/user";
import { ArrowRight, DotsThree, GearSix, Plus } from "@phosphor-icons/react"; import { ArrowRight, DotsThree, GearSix, Plus } from "@phosphor-icons/react";
import { Link, createLazyFileRoute } from "@tanstack/react-router"; import { Link, createLazyFileRoute } from "@tanstack/react-router";
import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
@@ -35,6 +33,7 @@ function Screen() {
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [autoLogin, setAutoLogin] = useState(false);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -48,30 +47,25 @@ function Screen() {
const selectAccount = (account: string) => { const selectAccount = (account: string) => {
setValue(account); setValue(account);
if (account.includes("_nostrconnect")) {
setAutoLogin(true);
}
}; };
const loginWith = () => { const loginWith = () => {
startTransition(async () => { startTransition(async () => {
if (!value || !password) return;
const res = await commands.login(value, password); const res = await commands.login(value, password);
if (res.status === "ok") { if (res.status === "ok") {
navigate({ navigate({
to: "/$account/chats/new", to: "/$account/chats",
params: { account: res.data }, params: { account: res.data },
replace: true, replace: true,
}); });
} else {
if (res.error === "404") {
navigate({
to: "/$account/relays",
params: { account: value },
replace: true,
});
} else { } else {
await message(res.error, { title: "Login", kind: "error" }); await message(res.error, { title: "Login", kind: "error" });
} return;
} }
}); });
}; };
@@ -81,6 +75,11 @@ function Screen() {
e.stopPropagation(); e.stopPropagation();
const menuItems = await Promise.all([ const menuItems = await Promise.all([
MenuItem.new({
text: "Reset password",
enabled: !account.includes("_nostrconnect"),
action: () => navigate({ to: "/reset", search: { account } }),
}),
MenuItem.new({ MenuItem.new({
text: "Delete account", text: "Delete account",
action: async () => await deleteAccount(account), action: async () => await deleteAccount(account),
@@ -96,6 +95,12 @@ function Screen() {
[], [],
); );
useEffect(() => {
if (autoLogin) {
loginWith();
}
}, [autoLogin, value]);
useEffect(() => { useEffect(() => {
setAccounts(context.accounts); setAccounts(context.accounts);
}, [context.accounts]); }, [context.accounts]);
@@ -123,10 +128,10 @@ function Screen() {
onKeyDown={() => selectAccount(account)} onKeyDown={() => selectAccount(account)}
className="group flex items-center gap-2 hover:bg-black/5 dark:hover:bg-white/5 p-3" className="group flex items-center gap-2 hover:bg-black/5 dark:hover:bg-white/5 p-3"
> >
<User.Provider pubkey={account}> <User.Provider pubkey={account.replace("_nostrconnect", "")}>
<User.Root className="flex-1 flex items-center gap-2.5"> <User.Root className="flex-1 flex items-center gap-2.5">
<User.Avatar className="rounded-full size-10" /> <User.Avatar className="rounded-full size-10" />
{value === account ? ( {value === account && !value.includes("_nostrconnect") ? (
<div className="flex-1 flex items-center gap-2"> <div className="flex-1 flex items-center gap-2">
<input <input
name="password" name="password"
@@ -137,14 +142,21 @@ function Screen() {
if (e.key === "Enter") loginWith(); if (e.key === "Enter") loginWith();
}} }}
placeholder="Password" placeholder="Password"
className="px-3 rounded-full w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600" className="px-3 rounded-full w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/> />
</div> </div>
) : ( ) : (
<div className="inline-flex flex-col items-start"> <div className="inline-flex flex-col items-start">
<div className="inline-flex items-center gap-1.5">
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" /> <User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
{account.includes("_nostrconnect") ? (
<div className="text-[8px] border border-blue-500 text-blue-500 px-1.5 rounded-full">
Nostr Connect
</div>
) : null}
</div>
<span className="text-sm text-neutral-700 dark:text-neutral-300"> <span className="text-sm text-neutral-700 dark:text-neutral-300">
{npub(account, 16)} {npub(account.replace("_nostrconnect", ""), 16)}
</span> </span>
</div> </div>
)} )}

View File

@@ -1,16 +1,12 @@
import { commands } from "@/commands"; import { commands } from "@/commands";
import { checkForAppUpdates, checkPermission } from "@/commons"; import { checkForAppUpdates } from "@/commons";
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: async () => { beforeLoad: async () => {
// Check for app updates // Check for app updates
// TODO: move this function to rust
await checkForAppUpdates(true); await checkForAppUpdates(true);
// Request notification permission
await checkPermission();
// Get all accounts from system // Get all accounts from system
const accounts = await commands.getAccounts(); const accounts = await commands.getAccounts();
@@ -21,6 +17,9 @@ export const Route = createFileRoute("/")({
}); });
} }
return { accounts }; // Workaround for keyring bug on Windows
const fil = accounts.filter((item) => !item.includes("Coop"));
return { accounts: fil };
}, },
}); });

View File

@@ -18,7 +18,7 @@ function Screen() {
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Link <Link
to="/create-account" to="/auth/new"
className="w-full h-10 bg-blue-500 font-medium hover:bg-blue-600 text-white rounded-lg inline-flex items-center justify-center shadow" className="w-full h-10 bg-blue-500 font-medium hover:bg-blue-600 text-white rounded-lg inline-flex items-center justify-center shadow"
> >
Create a new identity Create a new identity
@@ -32,7 +32,7 @@ function Screen() {
Login with Nostr Connect Login with Nostr Connect
</Link>*/} </Link>*/}
<Link <Link
to="/import-key" to="/auth/import"
className="w-full h-10 bg-white hover:bg-neutral-100 dark:hover:bg-neutral-100 dark:bg-white dark:text-black rounded-lg inline-flex items-center justify-center" className="w-full h-10 bg-white hover:bg-neutral-100 dark:hover:bg-neutral-100 dark:bg-white dark:text-black rounded-lg inline-flex items-center justify-center"
> >
Login with Private Key Login with Private Key

130
src/routes/reset.lazy.tsx Normal file
View File

@@ -0,0 +1,130 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import { readText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react";
import { Frame, GoBack, Spinner } from "@/components";
import { commands } from "@/commands";
export const Route = createLazyFileRoute("/reset")({
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const [key, setKey] = useState("");
const [password, setPassword] = useState("");
const [isPending, startTransition] = useTransition();
const pasteFromClipboard = async () => {
const val = await readText();
setKey(val);
};
const submit = () => {
startTransition(async () => {
if (!key.startsWith("nsec1")) {
await message(
"You need to enter a valid private key starts with nsec",
{ title: "Reset Password", kind: "info" },
);
return;
}
if (!password.length) {
await message("You must set password to secure your key", {
title: "Reset Password",
kind: "info",
});
return;
}
const res = await commands.resetPassword(key, password);
if (res.status === "ok") {
navigate({ to: "/", replace: true });
} else {
await message(res.error, {
title: "Reset Password",
kind: "error",
});
return;
}
});
};
return (
<div
data-tauri-drag-region
className="size-full flex items-center justify-center"
>
<div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold">
Reset password
</h1>
</div>
<div className="flex flex-col gap-3">
<Frame
className="flex flex-col gap-3 p-3 rounded-xl overflow-hidden"
shadow
>
<div className="flex flex-col gap-1.5">
<label
htmlFor="key"
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
>
Private Key
</label>
<div className="relative">
<input
name="key"
type="password"
placeholder="nsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
/>
<button
type="button"
onClick={() => pasteFromClipboard()}
className="absolute uppercase top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500"
>
Paste
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="password"
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
>
Set a new password
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none"
/>
</div>
</Frame>
<div className="flex flex-col items-center gap-1">
<button
type="button"
onClick={() => submit()}
disabled={isPending}
className="inline-flex items-center justify-center w-full h-9 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? <Spinner /> : "Continue"}
</button>
<GoBack className="mt-2 w-full text-sm text-neutral-600 dark:text-neutral-400 inline-flex items-center justify-center">
Go back to previous screen
</GoBack>
</div>
</div>
</div>
</div>
);
}