mirror of
https://github.com/aljazceru/landscape-template.git
synced 2026-01-31 12:14:30 +01:00
feat: base of linking api
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
// -----------------
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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<typeof AccountCard>;
|
||||
|
||||
|
||||
const Template: ComponentStory<typeof AccountCard> = (args) => <AccountCard {...args} ></AccountCard>
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="rounded-16 bg-white border-2 border-gray-200 p-24">
|
||||
<p className="text-body2 font-bold">Account Settings</p>
|
||||
|
||||
|
||||
<div className='mt-24 flex flex-col gap-16'>
|
||||
<p className="text-body3 font-bold">Linked Wallets</p>
|
||||
<p className="text-body4 text-gray-600">
|
||||
These are the wallets that you can login to this account from.
|
||||
<br />
|
||||
You can add a new wallet from the button below.
|
||||
</p>
|
||||
<Button color='primary' className='' onClick={connectNewWallet}>
|
||||
Connect new wallet ⚡
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<any>(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 = <div className="flex flex-col gap-24 items-center">
|
||||
<p className="text-body3 text-red-500 font-bold">Something wrong happened...</p>
|
||||
<a href='/login' className="text body4 text-gray-500 hover:underline">Refresh the page</a>
|
||||
</div>
|
||||
|
||||
else if (loadingLnurl)
|
||||
content = <div className="flex flex-col gap-24 items-center">
|
||||
<Grid color="var(--primary)" width="150" />
|
||||
<p className="text-body3 font-bold">Fetching Lnurl-Auth...</p>
|
||||
</div>
|
||||
|
||||
else
|
||||
content =
|
||||
<>
|
||||
<p className="text-body1 font-bolder text-center">
|
||||
Link your account ⚡
|
||||
</p>
|
||||
<QRCodeSVG
|
||||
width={160}
|
||||
height={160}
|
||||
value={lnurl}
|
||||
/>
|
||||
<p className="text-gray-600 text-body4 text-center">
|
||||
Scan this code or copy + paste it to your other lightning wallet to be able to login later with it to this account.
|
||||
<br />
|
||||
When done, click the button below to close this modal.
|
||||
</p>
|
||||
<div className="flex flex-col w-full gap-16">
|
||||
{/* <a href={lnurl}
|
||||
className='grow block text-body4 text-center text-white font-bolder bg-primary-500 hover:bg-primary-600 rounded-10 px-16 py-12 active:scale-90 transition-transform'
|
||||
>Click to connect <IoRocketOutline /></a> */}
|
||||
<Button
|
||||
color='gray'
|
||||
className='grow'
|
||||
onClick={copyToClipboard}
|
||||
fullWidth
|
||||
>{copied ? "Copied" : "Copy"} <FiCopy /></Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={onClose}
|
||||
fullWidth
|
||||
className='mt-16'
|
||||
>
|
||||
Done?
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
custom={direction}
|
||||
variants={modalCardVariants}
|
||||
initial='initial'
|
||||
animate="animate"
|
||||
exit='exit'
|
||||
className="modal-card w-full max-w-[326px] bg-white border-2 border-gray-200 rounded-16 p-16 flex flex-col gap-16 items-center"
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { lazyModal } from 'src/utils/helperFunctions';
|
||||
|
||||
export const { LazyComponent: LinkingAccountModal } = lazyModal(() => import('./LinkingAccountModal'))
|
||||
@@ -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() {
|
||||
<Routes>
|
||||
<Route index element={<Navigate to='my-profile' />} />
|
||||
<Route path='my-profile' element={<UpdateMyProfileCard data={profileQuery.data.profile} />} />
|
||||
<Route path='account' element={<AccountCard />} />
|
||||
<Route path='preferences' element={<CommentsSettingsCard nostr_prv_key={profileQuery.data.profile.nostr_prv_key} nostr_pub_key={profileQuery.data.profile.nostr_pub_key} isOwner={true} />
|
||||
} />
|
||||
</Routes>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user