improving login/subscription flow
This commit is contained in:
parent
bb6d3471ea
commit
e1d49e34d9
|
|
@ -21,4 +21,9 @@ AUTH_COOKIE_NAME=proselog.token
|
|||
ENCRYPT_SECRET=uIeqyCW1E5PbK2ztsc+UXcPwsZ0L/Z4k08ArOSaKpHHFViRbdnFJcQ==
|
||||
|
||||
MAILGUN_APIKEY=
|
||||
MAILGUN_DOMAIN=
|
||||
# use separate domain for transcantional email / newsletter email
|
||||
MAILGUN_DOMAIN_TRANSANCTION=
|
||||
MAILGUN_DOMAIN_NEWSLETTER=
|
||||
|
||||
# access token for our umami instance
|
||||
UMAMI_TOKEN=
|
||||
|
|
|
|||
|
|
@ -24,10 +24,8 @@ COPY --from=deps /app /app
|
|||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV production
|
||||
ENV BUILD_STEP=1
|
||||
RUN pnpm build
|
||||
# Remove devDependencies
|
||||
RUN pnpm prune --prod
|
||||
|
||||
|
||||
##### FINAL
|
||||
FROM base
|
||||
|
|
@ -35,10 +33,9 @@ FROM base
|
|||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=build /app/.next /app/.next
|
||||
COPY --from=build /app/.next/standalone /app
|
||||
COPY --from=build /app/public /app/public
|
||||
COPY --from=build /app/.next/static /app/.next/static
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@ module.exports = {
|
|||
},
|
||||
experimental: {
|
||||
scrollRestoration: true,
|
||||
outputStandalone: true,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"test-unit": "uvu src .test.ts -r esbuild-register",
|
||||
"test-e2e": "playwright test",
|
||||
"db-generate": "prisma generate",
|
||||
"start": "next start --port $PORT",
|
||||
"start": "node server.js",
|
||||
"db-seed": "node -r esbuild-register prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -19,7 +19,6 @@
|
|||
"@codemirror/language": "^0.20.0",
|
||||
"@codemirror/state": "^0.20.0",
|
||||
"@codemirror/view": "^0.20.3",
|
||||
"@hapi/iron": "^6.0.0",
|
||||
"@headlessui/react": "^1.6.0",
|
||||
"@prisma/client": "^3.14.0",
|
||||
"@proselog/jwt": "^0.1.1",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ specifiers:
|
|||
'@codemirror/language': ^0.20.0
|
||||
'@codemirror/state': ^0.20.0
|
||||
'@codemirror/view': ^0.20.3
|
||||
'@hapi/iron': ^6.0.0
|
||||
'@headlessui/react': ^1.6.0
|
||||
'@iconify/icons-mdi': ^1.2.15
|
||||
'@iconify/react': ^3.2.1
|
||||
|
|
@ -86,7 +85,6 @@ dependencies:
|
|||
'@codemirror/language': 0.20.0
|
||||
'@codemirror/state': 0.20.0
|
||||
'@codemirror/view': 0.20.4
|
||||
'@hapi/iron': 6.0.0
|
||||
'@headlessui/react': 1.6.1_ef5jwxihqo6n7gxfmzogljlgcm
|
||||
'@iconify/icons-mdi': 1.2.15
|
||||
'@iconify/react': 3.2.1
|
||||
|
|
@ -455,43 +453,6 @@ packages:
|
|||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@hapi/b64/5.0.0:
|
||||
resolution: {integrity: sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw==}
|
||||
dependencies:
|
||||
'@hapi/hoek': 9.3.0
|
||||
dev: false
|
||||
|
||||
/@hapi/boom/9.1.4:
|
||||
resolution: {integrity: sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==}
|
||||
dependencies:
|
||||
'@hapi/hoek': 9.3.0
|
||||
dev: false
|
||||
|
||||
/@hapi/bourne/2.1.0:
|
||||
resolution: {integrity: sha512-i1BpaNDVLJdRBEKeJWkVO6tYX6DMFBuwMhSuWqLsY4ufeTKGVuV5rBsUhxPayXqnnWHgXUAmWK16H/ykO5Wj4Q==}
|
||||
dev: false
|
||||
|
||||
/@hapi/cryptiles/5.1.0:
|
||||
resolution: {integrity: sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
dependencies:
|
||||
'@hapi/boom': 9.1.4
|
||||
dev: false
|
||||
|
||||
/@hapi/hoek/9.3.0:
|
||||
resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==}
|
||||
dev: false
|
||||
|
||||
/@hapi/iron/6.0.0:
|
||||
resolution: {integrity: sha512-zvGvWDufiTGpTJPG1Y/McN8UqWBu0k/xs/7l++HVU535NLHXsHhy54cfEMdW7EjwKfbBfM9Xy25FmTiobb7Hvw==}
|
||||
dependencies:
|
||||
'@hapi/b64': 5.0.0
|
||||
'@hapi/boom': 9.1.4
|
||||
'@hapi/bourne': 2.1.0
|
||||
'@hapi/cryptiles': 5.1.0
|
||||
'@hapi/hoek': 9.3.0
|
||||
dev: false
|
||||
|
||||
/@headlessui/react/1.6.1_ef5jwxihqo6n7gxfmzogljlgcm:
|
||||
resolution: {integrity: sha512-gMd6uIs1U4Oz718Z5gFoV0o/vD43/4zvbyiJN9Dt7PK9Ubxn+TmJwTmYwyNJc5KxxU1t0CmgTNgwZX9+4NjCnQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -530,6 +491,7 @@ packages:
|
|||
|
||||
/@iconify/types/1.1.0:
|
||||
resolution: {integrity: sha512-Jh0llaK2LRXQoYsorIH8maClebsnzTcve+7U3rQUSnC11X4jtPnFuyatqFLvMxZ8MLG8dB4zfHsbPfuvxluONw==}
|
||||
dev: false
|
||||
|
||||
/@jridgewell/gen-mapping/0.1.1:
|
||||
resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[analyticsId]` on the table `sites` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "sites" ADD COLUMN "analyticsId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sites_analyticsId_key" ON "sites"("analyticsId");
|
||||
|
|
@ -91,6 +91,7 @@ model Site {
|
|||
navigation Json?
|
||||
deletedAt DateTime?
|
||||
memberships Membership[]
|
||||
analyticsId String? @unique
|
||||
|
||||
@@map("sites")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import { useStore } from "~/lib/store"
|
|||
import { Dialog } from "@headlessui/react"
|
||||
import { trpc } from "~/lib/trpc"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useEffect } from "react"
|
||||
import { Modal } from "../ui/Modal"
|
||||
import { UniLink } from "../ui/UniLink"
|
||||
import { DOCS_DOMAIN } from "~/lib/env"
|
||||
|
||||
export const LoginModal: React.FC = () => {
|
||||
const [loginModalOpened, setLoginModalOpened] = useStore((store) => [
|
||||
|
|
@ -31,57 +33,64 @@ export const LoginModal: React.FC = () => {
|
|||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<Modal
|
||||
title={`Continue with Email`}
|
||||
open={loginModalOpened}
|
||||
onClose={() => setLoginModalOpened(false)}
|
||||
className="fixed z-10 inset-0 overflow-y-auto"
|
||||
setOpen={setLoginModalOpened}
|
||||
>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Dialog.Overlay className="fixed inset-0 bg-white opacity-75" />
|
||||
|
||||
<div className="relative bg-white rounded-lg overflow-hidden max-w-sm shadow-modal w-full mx-auto">
|
||||
<Dialog.Title className="bg-zinc-200 h-12 flex items-center px-3 text-sm text-zinc-600 justify-center">
|
||||
Continue with Email
|
||||
</Dialog.Title>
|
||||
|
||||
<div className="p-8">
|
||||
{data && (
|
||||
<div className="mb-5">
|
||||
We just emailed you with a link to log in, please check your
|
||||
inbox and spam folder in case you can{`'`}t find it.
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mb-5 text-red-500">{error.message}</div>}
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label
|
||||
className="block mb-1 font-medium text-zinc-600"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
className="input is-block"
|
||||
{...form.register("email")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
isBlock
|
||||
isLoading={requestLoginLinkStatus === "loading"}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="p-5">
|
||||
{data && (
|
||||
<div className="mb-5">
|
||||
We just emailed you with a link to log in, please check your inbox
|
||||
and spam folder in case you can{`'`}t find it.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mb-5 text-red-500">{error.message}</div>}
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label
|
||||
className="block mb-1 font-medium text-zinc-600"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
className="input is-block"
|
||||
{...form.register("email")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
isBlock
|
||||
isLoading={requestLoginLinkStatus === "loading"}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
By clicking Continue, you agree to our{" "}
|
||||
<UniLink
|
||||
href={`https://${DOCS_DOMAIN}/terms.html`}
|
||||
className="underline"
|
||||
>
|
||||
Terms of Service
|
||||
</UniLink>{" "}
|
||||
and{" "}
|
||||
<UniLink
|
||||
href={`https://${DOCS_DOMAIN}/privacy.html`}
|
||||
className="underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</UniLink>
|
||||
.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import toast from "react-hot-toast"
|
|||
import { trpc } from "~/lib/trpc"
|
||||
import { Input } from "../ui/Input"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useRouter } from "next/router"
|
||||
|
||||
export const SubscribeModal: React.FC<{
|
||||
siteId: string
|
||||
|
|
@ -14,6 +15,7 @@ export const SubscribeModal: React.FC<{
|
|||
} | null
|
||||
isLoggedIn: boolean
|
||||
}> = ({ siteId, subscription, isLoggedIn }) => {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useStore((store) => [
|
||||
store.subscribeModalOpened,
|
||||
store.setSubscribeModalOpened,
|
||||
|
|
@ -59,7 +61,21 @@ export const SubscribeModal: React.FC<{
|
|||
}, [unsubscribe, trpcContext])
|
||||
|
||||
return (
|
||||
<Modal title="Become a subscriber" open={open} setOpen={setOpen}>
|
||||
<Modal
|
||||
title={subscription ? `Manage your subscription` : `Become a subscriber`}
|
||||
open={open}
|
||||
setOpen={(open) => {
|
||||
setOpen(open)
|
||||
if (!open && "subscription" in router.query) {
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
query.delete("subscription")
|
||||
const search = query.toString()
|
||||
router.replace(
|
||||
`${window.location.pathname}${search ? `?${search}` : ""}`,
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{subscribe.isSuccess && !isLoggedIn ? (
|
||||
<div className="p-5 space-y-3">
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import { Icon } from "@iconify/react"
|
|||
import discordIcon from "@iconify/icons-mdi/discord"
|
||||
import githubIcon from "@iconify/icons-mdi/github"
|
||||
import { logout } from "~/lib/auth.client"
|
||||
import { APP_DESCRIPTION, APP_NAME, GITHUB_LINK, OUR_DOMAIN } from "~/lib/env"
|
||||
import {
|
||||
APP_DESCRIPTION,
|
||||
APP_NAME,
|
||||
DOCS_DOMAIN,
|
||||
GITHUB_LINK,
|
||||
OUR_DOMAIN,
|
||||
} from "~/lib/env"
|
||||
import { useStore } from "~/lib/store"
|
||||
import { SEOHead } from "../common/SEOHead"
|
||||
import { UniLink } from "../ui/UniLink"
|
||||
|
|
@ -17,13 +23,12 @@ export function MainLayout({
|
|||
title?: string
|
||||
}) {
|
||||
const setLoginModalOpened = useStore((store) => store.setLoginModalOpened)
|
||||
const docsDomain = `docs.${OUR_DOMAIN}`
|
||||
const discordLink = `https://${OUR_DOMAIN}/discord`
|
||||
const companyLinks = [
|
||||
{ text: "Blog", href: `https://blog.${OUR_DOMAIN}` },
|
||||
{ text: "Changelog", href: `https://changelog.${OUR_DOMAIN}` },
|
||||
{ text: "Privacy", href: `https://${docsDomain}/privacy.html` },
|
||||
{ text: "Terms", href: `https://${docsDomain}/terms.html` },
|
||||
{ text: "Privacy", href: `https://${DOCS_DOMAIN}/privacy.html` },
|
||||
{ text: "Terms", href: `https://${DOCS_DOMAIN}/terms.html` },
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const HeaderLink: React.FC<{ link: HeaderLinkType }> = ({ link }) => {
|
|||
onClick={link.onClick}
|
||||
className={clsx(
|
||||
`h-10 flex items-center border-b-2 space-x-1 hover:border-gray-500 hover:text-gray-700`,
|
||||
active ? `text-indigo-700 border-accent` : `border-transparent`
|
||||
active ? `text-indigo-700 border-accent` : `border-transparent`,
|
||||
)}
|
||||
>
|
||||
{link.icon && <span>{link.icon}</span>}
|
||||
|
|
@ -46,7 +46,7 @@ export const SiteHeader: React.FC<{
|
|||
viewer: Viewer | null
|
||||
}> = ({ siteName, description, icon, navigation, subscribed, viewer }) => {
|
||||
const setSubscribeModalOpened = useStore(
|
||||
(store) => store.setSubscribeModalOpened
|
||||
(store) => store.setSubscribeModalOpened,
|
||||
)
|
||||
const setLoginModalOpened = useStore((store) => store.setLoginModalOpened)
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ export const SiteHeader: React.FC<{
|
|||
const dropdownLinks: HeaderLinkType[] = [
|
||||
{
|
||||
icon: <DashboardIcon />,
|
||||
label: "Dashboard",
|
||||
label: "Writer dashboard",
|
||||
url: `${IS_PROD ? "https" : "http"}://${OUR_DOMAIN}/dashboard`,
|
||||
},
|
||||
{
|
||||
|
|
@ -150,7 +150,7 @@ export const SiteHeader: React.FC<{
|
|||
key={i}
|
||||
href={link.url}
|
||||
onClick={link.onClick}
|
||||
className="px-4 h-8 flex items-center w-full hover:bg-zinc-100"
|
||||
className="px-4 h-8 flex items-center w-full whitespace-nowrap hover:bg-zinc-100"
|
||||
>
|
||||
{link.label}
|
||||
</UniLink>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import clsx from "clsx"
|
||||
import React from "react"
|
||||
import React, { useEffect } from "react"
|
||||
import { getUserContentsUrl } from "~/lib/user-contents"
|
||||
import { SubscribeModal } from "../common/SubscribeModal"
|
||||
import { SEOHead } from "../common/SEOHead"
|
||||
import { SiteNavigationItem, Viewer } from "~/lib/types"
|
||||
import { SiteFooter } from "./SiteFooter"
|
||||
import { SiteHeader } from "./SiteHeader"
|
||||
import { useRouter } from "next/router"
|
||||
import { useStore } from "~/lib/store"
|
||||
|
||||
export type SiteLayoutProps = {
|
||||
site: {
|
||||
|
|
@ -30,6 +32,17 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
|
|||
title,
|
||||
ogDescription,
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const setSubscribeModalOpened = useStore(
|
||||
(store) => store.setSubscribeModalOpened,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if ("subscription" in router.query) {
|
||||
setSubscribeModalOpened(true)
|
||||
}
|
||||
}, [setSubscribeModalOpened, router.query])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEOHead
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
export const R2_API_TOKEN = process.env.R2_API_TOKEN
|
||||
export const MAILGUN_APIKEY = process.env.MAILGUN_APIKEY
|
||||
export const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN
|
||||
export const ENCRYPT_SECRET = process.env.ENCRYPT_SECRET
|
||||
export const MAILGUN_DOMAIN_TRANSANCTION =
|
||||
process.env.MAILGUN_DOMAIN_TRANSANCTION
|
||||
export const MAILGUN_DOMAIN_NEWSLETTER = process.env.MAILGUN_DOMAIN_NEWSLETTER
|
||||
export const ENCRYPT_SECRET = process.env.BUILD_STEP
|
||||
? // This won't be used in actual runtime
|
||||
"random_string_to_make_next_build_happy"
|
||||
: process.env.ENCRYPT_SECRET
|
||||
|
||||
export const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME
|
||||
export const PRIMARY_REGION = process.env.PRIMARY_REGION
|
||||
export const FLY_REGION = process.env.FLY_REGION || "local"
|
||||
export const IS_PRIMARY_REGION = FLY_REGION === PRIMARY_REGION
|
||||
export const UMAMI_TOKEN = process.env.UMAMI_TOKEN
|
||||
export const UMAMI_ENDPOINT = process.env.UMAMI_ENDPOINT
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { IS_PROD } from "./constants"
|
|||
|
||||
export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME
|
||||
export const OUR_DOMAIN = process.env.NEXT_PUBLIC_OUR_DOMAIN
|
||||
export const DOCS_DOMAIN = `docs.${OUR_DOMAIN}`
|
||||
export const SITE_URL = `${IS_PROD ? "https" : "http"}://${OUR_DOMAIN}`
|
||||
export const R2_URL = process.env.NEXT_PUBLIC_R2_URL
|
||||
export const APP_DESCRIPTION = process.env.APP_DESCRIPTION
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import Mailgun from "mailgun.js"
|
|||
import FormData from "form-data"
|
||||
import { singleton } from "./singleton.server"
|
||||
import {
|
||||
ENCRYPT_SECRET,
|
||||
MAILGUN_APIKEY,
|
||||
MAILGUN_DOMAIN,
|
||||
MAILGUN_DOMAIN_NEWSLETTER,
|
||||
MAILGUN_DOMAIN_TRANSANCTION,
|
||||
} from "~/lib/env.server"
|
||||
import type { MailgunMessageData } from "mailgun.js/interfaces/Messages"
|
||||
import { IS_PROD } from "./constants"
|
||||
|
|
@ -12,11 +12,17 @@ import { APP_NAME, OUR_DOMAIN, SITE_URL } from "./env"
|
|||
import { SubscribeFormData } from "./types"
|
||||
import { getSite } from "~/models/site.model"
|
||||
import { type Site } from "~/lib/db.server"
|
||||
import Iron from "@hapi/iron"
|
||||
import mjml from "mjml"
|
||||
import { getSiteLink } from "./helpers"
|
||||
import { generateLoginToken } from "./token.server"
|
||||
|
||||
const enableMailgun = Boolean(MAILGUN_APIKEY && MAILGUN_DOMAIN)
|
||||
enum MAIL_STREAM {
|
||||
NEWSLETTER = "newsletter",
|
||||
TRANSANCTION = "transaction",
|
||||
}
|
||||
|
||||
const enableMailgun = Boolean(
|
||||
MAILGUN_APIKEY && MAILGUN_DOMAIN_TRANSANCTION && MAILGUN_DOMAIN_NEWSLETTER,
|
||||
)
|
||||
|
||||
const getClient = () =>
|
||||
singleton("mailgun", () => {
|
||||
|
|
@ -29,35 +35,57 @@ const getClient = () =>
|
|||
return client
|
||||
})
|
||||
|
||||
const sendEmail = async (message: MailgunMessageData) => {
|
||||
const sendEmail = async (message: MailgunMessageData, stream: MAIL_STREAM) => {
|
||||
console.log(message)
|
||||
|
||||
if (!enableMailgun) {
|
||||
console.error(
|
||||
"not sending email because no mailgun apikey or domain configured"
|
||||
"not sending email because no mailgun apikey or domain configured",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const client = getClient()
|
||||
|
||||
await client.messages.create(MAILGUN_DOMAIN, message).then(console.log)
|
||||
await client.messages
|
||||
.create(
|
||||
stream === MAIL_STREAM.NEWSLETTER
|
||||
? MAILGUN_DOMAIN_NEWSLETTER!
|
||||
: MAILGUN_DOMAIN_TRANSANCTION!,
|
||||
message,
|
||||
)
|
||||
.then(console.log)
|
||||
}
|
||||
|
||||
const sendTransanctionEmail = async (message: MailgunMessageData) =>
|
||||
sendEmail(message, MAIL_STREAM.TRANSANCTION)
|
||||
|
||||
const sendNewsletterEmail = async (message: MailgunMessageData) =>
|
||||
sendEmail(message, MAIL_STREAM.NEWSLETTER)
|
||||
|
||||
export const sendLoginEmail = async (payload: {
|
||||
token: string
|
||||
email: string
|
||||
url: string
|
||||
subscribeForm?: SubscribeFormData
|
||||
toSubscribeSiteId?: string
|
||||
}) => {
|
||||
const { protocol, host, pathname } = new URL(payload.url)
|
||||
const token = await generateLoginToken(
|
||||
payload.toSubscribeSiteId
|
||||
? {
|
||||
type: "subscribe",
|
||||
email: payload.email,
|
||||
siteId: payload.toSubscribeSiteId,
|
||||
}
|
||||
: {
|
||||
type: "login",
|
||||
email: payload.email,
|
||||
},
|
||||
)
|
||||
const query = new URLSearchParams([
|
||||
["token", payload.token],
|
||||
["token", token],
|
||||
["next", `${protocol}//${host}${pathname}`],
|
||||
])
|
||||
if (payload.subscribeForm) {
|
||||
query.append("subscribe", JSON.stringify(payload.subscribeForm))
|
||||
}
|
||||
|
||||
const loginLink = `${
|
||||
IS_PROD ? "https" : "http"
|
||||
}://${OUR_DOMAIN}/api/login?${query.toString()}`
|
||||
|
|
@ -78,15 +106,15 @@ export const sendLoginEmail = async (payload: {
|
|||
<p>Your Proselog Team</p>
|
||||
`
|
||||
|
||||
if (payload.subscribeForm) {
|
||||
const site = await getSite(payload.subscribeForm.siteId)
|
||||
if (payload.toSubscribeSiteId) {
|
||||
const site = await getSite(payload.toSubscribeSiteId)
|
||||
subject = `Confirm your subscription to ${site.name}`
|
||||
html = `
|
||||
<p>Please confirm your subscription to ${site.name} by clicking the link below:</p>
|
||||
<p>Please confirm your subscription to "${site.name}" by clicking the link below:</p>
|
||||
|
||||
<a href="${loginLink}">confirm</a>
|
||||
|
||||
<p>This link will expire in 10 minutes.</p>`
|
||||
<p>This link will expire in 20 minutes.</p>`
|
||||
}
|
||||
|
||||
const message: MailgunMessageData = {
|
||||
|
|
@ -96,7 +124,7 @@ export const sendLoginEmail = async (payload: {
|
|||
html,
|
||||
}
|
||||
|
||||
await sendEmail(message)
|
||||
await sendTransanctionEmail(message)
|
||||
}
|
||||
|
||||
export const sendEmailForNewPost = async (payload: {
|
||||
|
|
@ -125,7 +153,7 @@ export const sendEmailForNewPost = async (payload: {
|
|||
|
||||
<p style="margin-top:3em;">
|
||||
<a
|
||||
href="${`${SITE_URL}/api/unsubscribe?token=%recipient.unsubscribeToken%`}"
|
||||
href="${`${SITE_URL}/api/login?next=/ignore&token=%recipient.unsubscribeToken%`}"
|
||||
style="text-decoration:none;">
|
||||
Unsubscribe
|
||||
</a>
|
||||
|
|
@ -146,29 +174,27 @@ export const sendEmailForNewPost = async (payload: {
|
|||
|
||||
await Promise.allSettled(
|
||||
payload.subscribers.map(async (sub) => {
|
||||
const unsubscribeToken = await Iron.seal(
|
||||
{
|
||||
userId: sub.id,
|
||||
siteId: payload.site.id,
|
||||
},
|
||||
ENCRYPT_SECRET,
|
||||
Iron.defaults
|
||||
)
|
||||
const unsubscribeToken = await generateLoginToken({
|
||||
type: "unsubscribe",
|
||||
userId: sub.id,
|
||||
siteId: payload.site.id,
|
||||
})
|
||||
recipientVariables[sub.email] = {
|
||||
unsubscribeToken,
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const message: MailgunMessageData = {
|
||||
from,
|
||||
subject,
|
||||
html,
|
||||
// TODO: segment by every 800 subscribers
|
||||
to: Object.keys(recipientVariables),
|
||||
"recipient-variables": JSON.stringify(recipientVariables),
|
||||
}
|
||||
|
||||
await sendEmail(message)
|
||||
await sendNewsletterEmail(message)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { decrypt, encrypt, getDerivedKey } from "@proselog/jwt"
|
||||
import { ENCRYPT_SECRET } from "./env.server"
|
||||
import { singletonAsync } from "./singleton.server"
|
||||
|
||||
const key = singletonAsync("encryption_key", () =>
|
||||
getDerivedKey(ENCRYPT_SECRET),
|
||||
)
|
||||
|
||||
export const generateEncryptedToken = async (payload: any) => {
|
||||
await key.wait
|
||||
const token = await encrypt(payload, key.value, {
|
||||
expiresIn: "20m",
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
export const decryptToken = async (token: string) => {
|
||||
await key.wait
|
||||
const payload = await decrypt(token, key.value)
|
||||
return payload
|
||||
}
|
||||
|
||||
type LoginTokenPayload =
|
||||
| { type: "login"; email: string }
|
||||
| { type: "subscribe"; email: string; siteId: string }
|
||||
| {
|
||||
type: "unsubscribe"
|
||||
siteId: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
export const generateLoginToken = async (payload: LoginTokenPayload) => {
|
||||
const token = await generateEncryptedToken(payload)
|
||||
return token
|
||||
}
|
||||
|
||||
export const decryptLoginToken = async (token: string) => {
|
||||
const payload = await decryptToken(token)
|
||||
return payload as LoginTokenPayload
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { OUR_DOMAIN } from "./env"
|
||||
import { UMAMI_ENDPOINT, UMAMI_TOKEN } from "./env.server"
|
||||
import { singleton } from "./singleton.server"
|
||||
|
||||
class Umami {
|
||||
constructor() {}
|
||||
|
||||
async request(
|
||||
url: string,
|
||||
options: {
|
||||
method: "GET" | "POST"
|
||||
query?: Record<string, any>
|
||||
data?: Record<string, any>
|
||||
},
|
||||
) {
|
||||
if (options.query) {
|
||||
url += `?${new URLSearchParams(options.query).toString()}`
|
||||
}
|
||||
const body = options.data ? JSON.stringify(options.data) : undefined
|
||||
return fetch(`https://${UMAMI_ENDPOINT}/api${url}`, {
|
||||
method: options.method,
|
||||
body,
|
||||
headers: {
|
||||
Authorization: `Bearer ${UMAMI_TOKEN}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
}).then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status} ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
})
|
||||
}
|
||||
|
||||
async createAnalytics(site: { id: string; name: string }) {
|
||||
const { website_uuid } = await this.request(`/website`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
domain: `${site.id}.${OUR_DOMAIN}`,
|
||||
name: site.name,
|
||||
enable_share_url: false,
|
||||
public: false,
|
||||
},
|
||||
})
|
||||
|
||||
return website_uuid
|
||||
}
|
||||
}
|
||||
|
||||
export const umami = singleton("umami", () => new Umami())
|
||||
|
|
@ -5,7 +5,7 @@ import rehypeStringify from "rehype-stringify"
|
|||
import remarkGfm from "remark-gfm"
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
import { refractor } from "refractor/lib/common"
|
||||
import { refractor } from "refractor/lib/all"
|
||||
import rehypePrismGenerator from "rehype-prism-plus/generator"
|
||||
import { rehypeImage } from "./rehype-image"
|
||||
import { remarkExcerpt } from "./remark-excerpt"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SiteNavigationItem, SubscribeFormData } from "~/lib/types"
|
|||
import { nanoid } from "nanoid"
|
||||
import { getMembership } from "./membership"
|
||||
import { checkReservedWords } from "~/lib/reserved-words"
|
||||
import { generateLoginToken } from "~/lib/token.server"
|
||||
|
||||
export const checkSubdomain = async ({
|
||||
subdomain,
|
||||
|
|
@ -111,7 +112,7 @@ export async function updateSite(
|
|||
icon?: string | null
|
||||
subdomain?: string
|
||||
navigation?: SiteNavigationItem[]
|
||||
}
|
||||
},
|
||||
) {
|
||||
const site = await getSite(payload.site)
|
||||
|
||||
|
|
@ -147,7 +148,7 @@ export async function updateSite(
|
|||
|
||||
export async function createSite(
|
||||
gate: Gate,
|
||||
payload: { name: string; subdomain: string }
|
||||
payload: { name: string; subdomain: string },
|
||||
) {
|
||||
const user = gate.getUser(true)
|
||||
await checkSubdomain({ subdomain: payload.subdomain })
|
||||
|
|
@ -205,27 +206,15 @@ export async function subscribeToSite(
|
|||
email: string
|
||||
url: string
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
const { newUser } = input
|
||||
if (newUser) {
|
||||
// Create the login token instead
|
||||
const subscribeFormData: SubscribeFormData = {
|
||||
email: input.email,
|
||||
siteId: input.siteId,
|
||||
}
|
||||
const loginToken = await prismaPrimary.loginToken.create({
|
||||
data: {
|
||||
email: newUser.email,
|
||||
expiresAt: dayjs().add(10, "minutes").toDate(),
|
||||
subscribeForm: subscribeFormData,
|
||||
},
|
||||
})
|
||||
sendLoginEmail({
|
||||
token: loginToken.id,
|
||||
email: loginToken.email,
|
||||
email: newUser.email,
|
||||
url: newUser.url,
|
||||
subscribeForm: subscribeFormData,
|
||||
toSubscribeSiteId: input.siteId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -272,7 +261,7 @@ export async function subscribeToSite(
|
|||
|
||||
export async function unsubscribeFromSite(
|
||||
gate: Gate,
|
||||
input: { siteId: string }
|
||||
input: { siteId: string },
|
||||
) {
|
||||
const user = gate.getUser(true)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ import { getTenant } from "~/lib/tenant.server"
|
|||
|
||||
const METHODS_TO_NOT_REPLAY = ["GET", "HEAD", "OPTIONS"]
|
||||
|
||||
const ALWAYS_REPLAY_ROUTES = [
|
||||
"/api/login",
|
||||
"/api/login-complete",
|
||||
"/api/logout",
|
||||
]
|
||||
|
||||
export default function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl
|
||||
|
||||
|
|
@ -14,7 +20,8 @@ export default function middleware(req: NextRequest) {
|
|||
if (
|
||||
IS_PROD &&
|
||||
!IS_PRIMARY_REGION &&
|
||||
!METHODS_TO_NOT_REPLAY.includes(req.method)
|
||||
(!METHODS_TO_NOT_REPLAY.includes(req.method) ||
|
||||
ALWAYS_REPLAY_ROUTES.includes(pathname))
|
||||
) {
|
||||
console.log("replayed", {
|
||||
PRIMARY_REGION,
|
||||
|
|
|
|||
|
|
@ -5,16 +5,22 @@ import { IS_PROD } from "~/lib/constants"
|
|||
import { prismaPrimary } from "~/lib/db.server"
|
||||
import { OUR_DOMAIN } from "~/lib/env"
|
||||
|
||||
const pathAndSearch = (pathname: string, search: string) => {
|
||||
return `${pathname}${search ? `?${search}` : ""}`
|
||||
}
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
const data = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
path: z.string().default("/"),
|
||||
pathname: z.string().default("/"),
|
||||
search: z.string().default(""),
|
||||
host: z.string(),
|
||||
})
|
||||
.parse({
|
||||
id: req.query.id,
|
||||
path: req.query.path,
|
||||
pathname: req.query.pathname,
|
||||
search: req.query.search,
|
||||
host: req.headers.host,
|
||||
})
|
||||
|
||||
|
|
@ -52,13 +58,13 @@ const handler: NextApiHandler = async (req, res) => {
|
|||
type: "auth",
|
||||
domain: data.host,
|
||||
token: accessToken.token,
|
||||
})
|
||||
}),
|
||||
)
|
||||
res.redirect(data.path)
|
||||
res.redirect(pathAndSearch(data.pathname, data.search))
|
||||
return
|
||||
}
|
||||
|
||||
res.redirect(data.path)
|
||||
res.redirect(pathAndSearch(data.pathname, data.search))
|
||||
}
|
||||
|
||||
export default handler
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import { generateCookie } from "~/lib/auth.server"
|
|||
import { OUR_DOMAIN } from "~/lib/env"
|
||||
import { IS_PROD } from "~/lib/constants"
|
||||
import { NextApiHandler } from "next"
|
||||
import { subscribeToSite } from "~/models/site.model"
|
||||
import { getSite, subscribeToSite } from "~/models/site.model"
|
||||
import { createGate } from "~/lib/gate.server"
|
||||
import { decryptLoginToken } from "~/lib/token.server"
|
||||
import { getSiteLink } from "~/lib/helpers"
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
const data = z
|
||||
|
|
@ -16,72 +18,55 @@ const handler: NextApiHandler = async (req, res) => {
|
|||
token: z.string(),
|
||||
next: z.string(),
|
||||
userAgent: z.string(),
|
||||
subscribe: z
|
||||
.object({
|
||||
siteId: z.string(),
|
||||
email: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.parse({
|
||||
token: req.query.token,
|
||||
next: req.query.next,
|
||||
userAgent: req.headers["user-agent"],
|
||||
subscribe:
|
||||
req.query.subscribe && JSON.parse(req.query.subscribe as string),
|
||||
})
|
||||
|
||||
const loginToken = await prismaPrimary.loginToken.findUnique({
|
||||
where: {
|
||||
id: data.token,
|
||||
},
|
||||
})
|
||||
|
||||
if (!loginToken) {
|
||||
throw new Error("invalid token")
|
||||
}
|
||||
|
||||
if (loginToken.expiresAt < new Date()) {
|
||||
throw new Error(`token expired`)
|
||||
}
|
||||
const payload = await decryptLoginToken(data.token)
|
||||
|
||||
let user = await prismaPrimary.user.findUnique({
|
||||
where: {
|
||||
email: loginToken.email,
|
||||
},
|
||||
where:
|
||||
payload.type === "unsubscribe"
|
||||
? {
|
||||
id: payload.userId,
|
||||
}
|
||||
: {
|
||||
email: payload.email,
|
||||
},
|
||||
include: {
|
||||
memberships: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
user = await prismaPrimary.user.create({
|
||||
data: {
|
||||
email: loginToken.email,
|
||||
name: loginToken.email.split("@")[0],
|
||||
username: nanoid(7),
|
||||
},
|
||||
include: {
|
||||
memberships: true,
|
||||
},
|
||||
})
|
||||
if (payload.type === "unsubscribe") {
|
||||
throw new Error("User not found")
|
||||
} else {
|
||||
user = await prismaPrimary.user.create({
|
||||
data: {
|
||||
email: payload.email,
|
||||
name: payload.email.split("@")[0],
|
||||
username: nanoid(7),
|
||||
},
|
||||
include: {
|
||||
memberships: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (data.subscribe) {
|
||||
if (payload.type === "subscribe") {
|
||||
// Subscribe the user to the site
|
||||
const gate = createGate({ user })
|
||||
await subscribeToSite(gate, {
|
||||
siteId: data.subscribe.siteId,
|
||||
email: data.subscribe.email,
|
||||
siteId: payload.siteId,
|
||||
email: true,
|
||||
})
|
||||
}
|
||||
|
||||
await prismaPrimary.loginToken.delete({
|
||||
where: {
|
||||
id: loginToken.id,
|
||||
},
|
||||
})
|
||||
|
||||
const ua = new UAParser(data.userAgent)
|
||||
|
||||
const publicId = nanoid(32)
|
||||
|
|
@ -103,9 +88,16 @@ const handler: NextApiHandler = async (req, res) => {
|
|||
console.log("login with token", accessToken.token)
|
||||
}
|
||||
|
||||
// Custom domain
|
||||
if (payload.type === "unsubscribe") {
|
||||
const site = await getSite(payload.siteId)
|
||||
data.next = `${getSiteLink({
|
||||
subdomain: site.subdomain,
|
||||
})}?subscription`
|
||||
}
|
||||
|
||||
const nextUrl = new URL(data.next)
|
||||
|
||||
// Custom domain
|
||||
if (nextUrl.host !== OUR_DOMAIN && !nextUrl.host.endsWith(`.${OUR_DOMAIN}`)) {
|
||||
// Check if the host belong to a site
|
||||
// const existing = await prismaPrimary.domain.findUnique({
|
||||
|
|
@ -120,7 +112,7 @@ const handler: NextApiHandler = async (req, res) => {
|
|||
}
|
||||
|
||||
nextUrl.searchParams.set("id", publicId)
|
||||
nextUrl.searchParams.set("path", nextUrl.pathname)
|
||||
nextUrl.searchParams.set("pathname", nextUrl.pathname)
|
||||
nextUrl.pathname = "/api/login-complete"
|
||||
console.log("redirecting to", nextUrl.href)
|
||||
|
||||
|
|
@ -130,7 +122,7 @@ const handler: NextApiHandler = async (req, res) => {
|
|||
type: "auth",
|
||||
domain: OUR_DOMAIN,
|
||||
token: accessToken.token,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
res.redirect(nextUrl.href)
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import { NextApiHandler } from "next"
|
||||
import Iron from "@hapi/iron"
|
||||
import { ENCRYPT_SECRET } from "~/lib/env.server"
|
||||
import { unsubscribeFromSite } from "~/models/site.model"
|
||||
import { prismaPrimary } from "~/lib/db.server"
|
||||
import { createGate } from "~/lib/gate.server"
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
const token = req.query.token as string
|
||||
const payload: {
|
||||
siteId: string
|
||||
userId: string
|
||||
} = await Iron.unseal(token, ENCRYPT_SECRET, Iron.defaults)
|
||||
|
||||
const user = await prismaPrimary.user.findUnique({
|
||||
where: {
|
||||
id: payload.userId,
|
||||
},
|
||||
include: {
|
||||
memberships: true,
|
||||
},
|
||||
})
|
||||
if (!user) return res.send("invalid token")
|
||||
|
||||
const gate = createGate({ user })
|
||||
|
||||
await unsubscribeFromSite(gate, { siteId: payload.siteId })
|
||||
|
||||
res.send("unsubscribed!")
|
||||
}
|
||||
|
||||
export default handler
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import dayjs from "dayjs"
|
||||
import { z } from "zod"
|
||||
import { prismaPrimary } from "~/lib/db.server"
|
||||
import { sendLoginEmail } from "~/lib/mailgun.server"
|
||||
import { createRouter } from "~/lib/trpc.server"
|
||||
import { getViewer } from "~/lib/viewer"
|
||||
|
|
@ -28,17 +26,9 @@ export const authRouter = createRouter()
|
|||
}),
|
||||
output: z.boolean(),
|
||||
async resolve({ input }) {
|
||||
const token = await prismaPrimary.loginToken.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
expiresAt: dayjs().add(10, "minute").toDate(),
|
||||
},
|
||||
})
|
||||
|
||||
await sendLoginEmail({
|
||||
url: input.url,
|
||||
email: input.email,
|
||||
token: token.id,
|
||||
}).catch(console.error)
|
||||
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export const siteRouter = createRouter()
|
|||
slug: z.string(),
|
||||
excerpt: z.string().nullable(),
|
||||
autoExcerpt: z.string(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
total: z.number(),
|
||||
hasMore: z.boolean(),
|
||||
|
|
@ -118,7 +118,7 @@ export const siteRouter = createRouter()
|
|||
id: z.string(),
|
||||
name: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
|
|
@ -143,9 +143,9 @@ export const siteRouter = createRouter()
|
|||
.string()
|
||||
.regex(
|
||||
/^(https?:\/\/|\/)/,
|
||||
"URL must start with / or http:// or https://"
|
||||
"URL must start with / or http:// or https://",
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ declare interface CustomEnv {
|
|||
AUTH_COOKIE_NAME: string
|
||||
NEXT_PUBLIC_OUR_DOMAIN: string
|
||||
MAILGUN_APIKEY: string
|
||||
MAILGUN_DOMAIN: string
|
||||
MAILGUN_DOMAIN_TRANSANCTION?: string
|
||||
MAILGUN_DOMAIN_NEWSLETTER?: string
|
||||
FLY_REGION?: string
|
||||
PRIMARY_REGION?: string
|
||||
ENCRYPT_SECRET: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue