diff --git a/api/auth/services/lnurlAuth.service.js b/api/auth/services/lnurlAuth.service.js index c8f3ee0..1dcb91c 100644 --- a/api/auth/services/lnurlAuth.service.js +++ b/api/auth/services/lnurlAuth.service.js @@ -58,12 +58,15 @@ function removeExpiredHashes() { -async function generateAuthUrl() { +async function generateAuthUrl(options) { const hostname = CONSTS.LNURL_AUTH_HOST ?? 'https://auth.bolt.fun/.netlify/functions/login'; const secret = await generateK1(); const hash = createHash(secret); await addHash(hash) - const url = `${hostname}?tag=login&k1=${secret}` + let url = `${hostname}?tag=login&k1=${secret}` + if (options.user_token) { + url = url + `&action=link&user_token=${options.user_token}` + } return { url, encoded: lnurl.encode(url).toUpperCase(), diff --git a/api/auth/utils/helperFuncs.js b/api/auth/utils/helperFuncs.js index 975bb2a..54fbf09 100644 --- a/api/auth/utils/helperFuncs.js +++ b/api/auth/utils/helperFuncs.js @@ -3,9 +3,11 @@ const { prisma } = require('../../prisma') const getUserByPubKey = (pubKey) => { if (!pubKey) return null; - return prisma.user.findFirst({ - where: { pubKey } - }) + return prisma.userKey.findUnique({ + where: { + key: pubKey + }, + }).user() } diff --git a/api/functions/get-login-url/get-login-url.js b/api/functions/get-login-url/get-login-url.js index a22c467..f2f011b 100644 --- a/api/functions/get-login-url/get-login-url.js +++ b/api/functions/get-login-url/get-login-url.js @@ -5,14 +5,35 @@ const { createExpressApp } = require('../../modules'); const express = require('express'); const jose = require('jose'); const { JWT_SECRET } = require('../../utils/consts'); +const extractKeyFromCookie = require('../../utils/extractKeyFromCookie'); +const { getUserByPubKey } = require('../../auth/utils/helperFuncs'); const getLoginUrl = async (req, res) => { + + const { action } = req.query; + try { - const data = await LnurlAuthService.generateAuthUrl(); + + let user_token = null; + if (action === 'link') { + const userPubKey = await extractKeyFromCookie(req.headers.cookie ?? req.headers.Cookie) + const user = await getUserByPubKey(userPubKey); + + if (!user) + return res.status(400).json({ status: 'ERROR', reason: 'Only authenticated user can request a linking URL' }); + + user_token = await new jose.SignJWT({ user_id: user.id }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('5min') + .sign(Buffer.from(JWT_SECRET, 'utf-8')) + } + + const data = await LnurlAuthService.generateAuthUrl({ user_token }); const session_token = await new jose.SignJWT({ hash: data.secretHash }) .setProtectedHeader({ alg: 'HS256' }) diff --git a/api/functions/login/login.js b/api/functions/login/login.js index fb240e4..7dcdbf2 100644 --- a/api/functions/login/login.js +++ b/api/functions/login/login.js @@ -8,11 +8,13 @@ const express = require('express'); const jose = require('jose'); const { JWT_SECRET } = require('../../utils/consts'); const { generatePrivateKey, getPublicKey } = require('../../utils/nostr-tools'); +const { getUserByPubKey } = require('../../auth/utils/helperFuncs'); const loginHandler = async (req, res) => { - const { tag, k1, sig, key } = req.query; + + const { tag, k1, sig, key, action, user_token } = req.query; if (tag !== 'login') return res.status(400).json({ status: 'ERROR', reason: 'Invalid Tag Provided' }) @@ -24,24 +26,63 @@ const loginHandler = async (req, res) => { } + if (action === 'link' && user_token) { + try { + const { payload } = await jose.jwtVerify(user_token, Buffer.from(JWT_SECRET), { + algorithms: ['HS256'], + }) + const user_id = payload.user_id; + + const existingKeys = await prisma.userKey.findMany({ where: { user_id }, select: { key: true } }); + + if (existingKeys.length >= 3) + return res.status(400).json({ status: 'ERROR', reason: "Can only link up to 3 wallets" }) + + if (existingKeys.includes(key)) + return res.status(400).json({ status: 'ERROR', reason: "Wallet already linked" }) + + + await prisma.userKey.create({ + data: { + key, + user_id, + } + }); + + return res + .status(200) + .json({ status: "OK" }) + + } catch (error) { + return res.status(400).json({ status: 'ERROR', reason: 'Invalid User Token' }) + } + } + try { //Create user if not already existing - const user = await prisma.user.findFirst({ where: { pubKey: key } }) + const user = await getUserByPubKey(key) if (user === null) { const nostr_prv_key = generatePrivateKey(); const nostr_pub_key = getPublicKey(nostr_prv_key); - await prisma.user.create({ + const createdUser = await prisma.user.create({ data: { pubKey: key, name: key, avatar: `https://avatars.dicebear.com/api/bottts/${key}.svg`, nostr_prv_key, nostr_pub_key, - } + }, }) + await prisma.userKey.create({ + data: { + key, + user_id: createdUser.id, + } + }); + } // calc the hash of k1 diff --git a/prisma/migrations/20220808073740_add_userkeys_table/migration.sql b/prisma/migrations/20220808073740_add_userkeys_table/migration.sql new file mode 100644 index 0000000..1d6b0e2 --- /dev/null +++ b/prisma/migrations/20220808073740_add_userkeys_table/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "UserKey" ( + "key" TEXT NOT NULL, + "user_id" INTEGER, + + CONSTRAINT "UserKey_pkey" PRIMARY KEY ("key") +); + +-- AddForeignKey +ALTER TABLE "UserKey" ADD CONSTRAINT "UserKey_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5e9048e..5042441 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,6 +64,14 @@ model User { questions Question[] posts_comments PostComment[] donations Donation[] + userKeys UserKey[] +} + +model UserKey { + key String @id + + user User? @relation(fields: [user_id], references: [id]) + user_id Int? } // ----------------- diff --git a/prisma/seed/index.js b/prisma/seed/index.js index 6ee6d59..1484395 100644 --- a/prisma/seed/index.js +++ b/prisma/seed/index.js @@ -58,6 +58,8 @@ async function main() { // await createHackathons(); + await fillUserKeysTable() + } async function createCategories() { @@ -150,6 +152,22 @@ async function createHackathons() { } } +async function fillUserKeysTable() { + console.log('Filling Users Keys Table'); + const allUsers = await prisma.user.findMany({ + select: { + id: true, + pubKey: true, + } + }) + + await prisma.userKey.createMany({ + data: allUsers.filter(u => !!u.pubKey).map(u => ({ + key: u.pubKey, + user_id: u.id + })) + }) +} diff --git a/src/features/Profiles/pages/EditProfilePage/AccountCard/AccountCard.stories.tsx b/src/features/Profiles/pages/EditProfilePage/AccountCard/AccountCard.stories.tsx new file mode 100644 index 0000000..7463ed6 --- /dev/null +++ b/src/features/Profiles/pages/EditProfilePage/AccountCard/AccountCard.stories.tsx @@ -0,0 +1,19 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import AccountCard from './AccountCard'; + +export default { + title: 'Profiles/Profile Page/Account Card', + component: AccountCard, + argTypes: { + backgroundColor: { control: 'color' }, + }, + +} as ComponentMeta; + + +const Template: ComponentStory = (args) => + +export const Default = Template.bind({}); +Default.args = { + +} diff --git a/src/features/Profiles/pages/EditProfilePage/AccountCard/AccountCard.tsx b/src/features/Profiles/pages/EditProfilePage/AccountCard/AccountCard.tsx new file mode 100644 index 0000000..b27b49e --- /dev/null +++ b/src/features/Profiles/pages/EditProfilePage/AccountCard/AccountCard.tsx @@ -0,0 +1,39 @@ +import Button from 'src/Components/Button/Button'; +import { useAppDispatch } from 'src/utils/hooks'; +import { openModal } from 'src/redux/features/modals.slice'; + + +interface Props { + +} + +export default function AccountCard({ }: Props) { + + const dispatch = useAppDispatch() + + const connectNewWallet = () => { + dispatch(openModal({ Modal: "LinkingAccountModal" })) + } + + + return ( +
+

Account Settings

+ + +
+

Linked Wallets

+

+ These are the wallets that you can login to this account from. +
+ You can add a new wallet from the button below. +

+ +
+ + +
+ ) +} diff --git a/src/features/Profiles/pages/EditProfilePage/AccountCard/LinkingAccountModal/LinkingAccountModal.tsx b/src/features/Profiles/pages/EditProfilePage/AccountCard/LinkingAccountModal/LinkingAccountModal.tsx new file mode 100644 index 0000000..4449e12 --- /dev/null +++ b/src/features/Profiles/pages/EditProfilePage/AccountCard/LinkingAccountModal/LinkingAccountModal.tsx @@ -0,0 +1,143 @@ +import { motion } from 'framer-motion' +import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer' +import { useEffect, useState } from "react" +import { Grid } from "react-loader-spinner"; +import { CONSTS } from "src/utils"; +import { QRCodeSVG } from 'qrcode.react'; +import Button from "src/Components/Button/Button"; +import { FiCopy } from "react-icons/fi"; +import useCopyToClipboard from "src/utils/hooks/useCopyToClipboard"; + + + +const fetchLnurlAuth = async () => { + const res = await fetch(CONSTS.apiEndpoint + '/get-login-url', { + credentials: 'include' + }) + const data = await res.json() + return data; +} + +const useLnurlQuery = () => { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null); + const [data, setData] = useState<{ lnurl: string, session_token: string }>({ lnurl: '', session_token: '' }) + + + useEffect(() => { + + let timeOut: NodeJS.Timeout; + const doFetch = async () => { + const res = await fetchLnurlAuth(); + if (!res?.encoded) + setError(true) + else { + setLoading(false); + setData({ + lnurl: res.encoded, + session_token: res.session_token + }); + timeOut = setTimeout(doFetch, 1000 * 60 * 2) + } + } + doFetch() + + return () => clearTimeout(timeOut) + }, []) + + return { + loadingLnurl: loading, + error, + data + } +} + +export default function LinkingAccountModal({ onClose, direction, ...props }: ModalCard) { + + const [copied, setCopied] = useState(false); + + const { loadingLnurl, data: { lnurl }, error } = useLnurlQuery(); + const clipboard = useCopyToClipboard() + + + + useEffect(() => { + setCopied(false); + }, [lnurl]) + + + const copyToClipboard = () => { + setCopied(true); + clipboard(lnurl); + } + + + let content = <> + + if (error) + content =
+

Something wrong happened...

+ Refresh the page +
+ + else if (loadingLnurl) + content =
+ +

Fetching Lnurl-Auth...

+
+ + else + content = + <> +

+ Link your account ⚡ +

+ +

+ Scan this code or copy + paste it to your other lightning wallet to be able to login later with it to this account. +
+ When done, click the button below to close this modal. +

+
+ {/* Click to connect */} + + +
+ + + + + return ( + + {content} + + ) +} + + + diff --git a/src/features/Profiles/pages/EditProfilePage/AccountCard/LinkingAccountModal/index.ts b/src/features/Profiles/pages/EditProfilePage/AccountCard/LinkingAccountModal/index.ts new file mode 100644 index 0000000..a97d6f3 --- /dev/null +++ b/src/features/Profiles/pages/EditProfilePage/AccountCard/LinkingAccountModal/index.ts @@ -0,0 +1,3 @@ +import { lazyModal } from 'src/utils/helperFunctions'; + +export const { LazyComponent: LinkingAccountModal } = lazyModal(() => import('./LinkingAccountModal')) \ No newline at end of file diff --git a/src/features/Profiles/pages/EditProfilePage/EditProfilePage.tsx b/src/features/Profiles/pages/EditProfilePage/EditProfilePage.tsx index 500aadf..8382d21 100644 --- a/src/features/Profiles/pages/EditProfilePage/EditProfilePage.tsx +++ b/src/features/Profiles/pages/EditProfilePage/EditProfilePage.tsx @@ -8,6 +8,7 @@ import CommentsSettingsCard from "../ProfilePage/CommentsSettingsCard/CommentsSe import UpdateMyProfileCard from "./UpdateMyProfileCard/UpdateMyProfileCard"; import { Helmet } from 'react-helmet' import { MEDIA_QUERIES } from "src/utils/theme"; +import AccountCard from "./AccountCard/AccountCard"; const links = [ @@ -15,6 +16,10 @@ const links = [ text: "👾 My Profile", path: 'my-profile', }, + { + text: "🙍‍♂️ Account", + path: 'account', + }, { text: "⚙️ Preferences", path: 'preferences', @@ -92,6 +97,7 @@ export default function EditProfilePage() { } /> } /> + } /> } /> diff --git a/src/redux/features/modals.slice.ts b/src/redux/features/modals.slice.ts index c5dd20c..a789876 100644 --- a/src/redux/features/modals.slice.ts +++ b/src/redux/features/modals.slice.ts @@ -7,6 +7,7 @@ import { InsertVideoModal } from 'src/Components/Inputs/TextEditor/InsertVideoMo import { Claim_FundWithdrawCard, Claim_CopySignatureCard, Claim_GenerateSignatureCard, Claim_SubmittedCard } from "src/features/Projects/pages/ProjectPage/ClaimProject"; import { ModalCard } from "src/Components/Modals/ModalsContainer/ModalsContainer"; import { ConfirmModal } from "src/Components/Modals/ConfirmModal"; +import { LinkingAccountModal } from "src/features/Profiles/pages/EditProfilePage/AccountCard/LinkingAccountModal"; import { ComponentProps } from "react"; import { generateId } from "src/utils/helperFunctions"; @@ -32,6 +33,7 @@ export const ALL_MODALS = { Claim_SubmittedCard, Claim_FundWithdrawCard, ConfirmModal, + LinkingAccountModal, // Text Editor Modals InsertImageModal,