diff --git a/bun.lockb b/bun.lockb index 00760e7..3a2af9f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/infra/api.ts b/infra/api.ts index 39537d3..79f765c 100644 --- a/infra/api.ts +++ b/infra/api.ts @@ -1,3 +1,4 @@ +import { auth } from './auth' import { domain } from './dns' import { email } from './email' import { secret } from './secret' @@ -6,7 +7,7 @@ import { webhook } from './stripe' export const api = new sst.aws.Function('Api', { url: true, handler: 'packages/functions/api/index.handler', - link: [secret.DATABASE_URL, secret.STRIPE_SECRET_KEY, webhook, email], + link: [secret.DATABASE_URL, secret.STRIPE_SECRET_KEY, webhook, email, auth], permissions: [ { actions: ['ses:SendEmail'], diff --git a/infra/auth.ts b/infra/auth.ts new file mode 100644 index 0000000..9424293 --- /dev/null +++ b/infra/auth.ts @@ -0,0 +1,39 @@ +import { domain } from './dns' +import { email } from './email' +import { secret } from './secret' + +export const authTable = new sst.aws.Dynamo('LambdaAuthTable', { + fields: { + pk: 'string', + sk: 'string', + }, + ttl: 'expiry', + primaryIndex: { + hashKey: 'pk', + rangeKey: 'sk', + }, +}) + +export const auth = new sst.aws.Auth('Auth', { + forceUpgrade: 'v2', + authorizer: { + handler: 'packages/functions/auth.handler', + link: [ + email, + authTable, + secret.GITHUB_CLIENT_ID, + secret.GITHUB_CLIENT_SECRET, + secret.DATABASE_URL, + ], + permissions: [ + { + actions: ['ses:SendEmail'], + resources: ['*'], + }, + ], + }, + domain: { + name: `auth.${domain}`, + dns: sst.cloudflare.dns(), + }, +}) diff --git a/infra/www.ts b/infra/www.ts index 4179e42..86c5d2c 100644 --- a/infra/www.ts +++ b/infra/www.ts @@ -1,3 +1,4 @@ +import { auth } from './auth' import { domain } from './dns' import { email } from './email' import { secret } from './secret' @@ -20,6 +21,7 @@ export const www = new sst.aws.React('ReactRouter', { ], link: [ email, + auth, secret.SESSION_SECRET, secret.ENCRYPTION_SECRET, secret.DATABASE_URL, diff --git a/packages/core/package.json b/packages/core/package.json index a0996c5..bfa98e5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,10 +15,12 @@ "dependencies": { "@aws-sdk/client-sesv2": "^3.687.0", "@neondatabase/serverless": "^0.9.5", - "stripe": "^15.5.0", - "sst": "^3.3.27", + "@openauthjs/openauth": "^0.3.2", "drizzle-orm": "^0.36.2", + "sst": "^3.3.27", + "stripe": "^15.5.0", "ulid": "^2.3.0", + "valibot": "^1.0.0-beta.11", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/core/src/plan/seed.ts b/packages/core/src/plan/seed.ts index a9fdec0..21288cf 100644 --- a/packages/core/src/plan/seed.ts +++ b/packages/core/src/plan/seed.ts @@ -1,23 +1,28 @@ import { Stripe } from '@company/core/src/stripe' import { db, schema } from '../drizzle' +import { PRICING_PLANS } from '../constants' export default async function seed() { const prices = await Stripe.client.prices.list() const products = await Stripe.client.products.list() + const stage = JSON.parse(process.env.SST_RESOURCE_App as string).stage + const activeProducts = products.data.filter( + (p) => p.active && p.metadata.stage === stage, + ) - for (const { id, name, description } of products.data.filter((p) => p.active)) { + for (const { id, name, description } of Object.values(PRICING_PLANS)) { + const stripeProduct = activeProducts.find((p) => p.name === name)! await db.transaction(async (tx) => { - const [plan] = await tx - .insert(schema.plan) - .values({ id, name, description }) - .returning() - + await tx.insert(schema.plan).values({ id, name, description }) await tx.insert(schema.price).values( prices.data - .filter((price) => price.product === id) + .filter( + (price) => + price.product === stripeProduct.id && price.metadata.stage === stage, + ) .map((price) => ({ id: price.id, - planId: plan!.id, + planId: id, amount: price.unit_amount ?? 0, currency: price.currency, interval: price.recurring?.interval ?? 'month', diff --git a/packages/core/src/subscription/index.ts b/packages/core/src/subscription/index.ts index ccb89dd..9cc9a16 100644 --- a/packages/core/src/subscription/index.ts +++ b/packages/core/src/subscription/index.ts @@ -16,11 +16,15 @@ export namespace Subscription { }) } - export const insert = async (userID: string, sub: Stripe.Subscription) => { + export const insert = async ( + userID: string, + sub: Stripe.Subscription, + planId: string, + ) => { await db.insert(schema).values({ id: sub.id, userId: userID, - planId: String(sub.items.data[0]!.plan.product), + planId, priceId: String(sub.items.data[0]!.price.id), interval: String(sub.items.data[0]!.plan.interval), status: sub.status, diff --git a/packages/core/src/user/index.ts b/packages/core/src/user/index.ts index 1affc65..78f7e50 100644 --- a/packages/core/src/user/index.ts +++ b/packages/core/src/user/index.ts @@ -1,8 +1,48 @@ import { eq } from 'drizzle-orm' +import { createSubjects } from '@openauthjs/openauth/subject' import { db } from '../drizzle' import { user as schema, userImage as imageSchema } from './sql' +import { role, roleToUser } from '../role/sql' +import { Role } from '../role' +// TODO: switch to zod once @conform-to/zod can handle new zod version +import * as v from 'valibot' +import type { InferOutput } from 'valibot' export namespace User { + export const info = v.object({ + id: v.string(), + email: v.pipe(v.string(), v.email()), + username: v.nullable(v.string()), + customerId: v.nullable(v.string()), + roles: v.array( + v.object({ + name: v.union(Role.roles.map((role) => v.literal(role))), + id: v.string(), + }), + ), + }) + + export type info = InferOutput + + export const subjects = createSubjects({ user: info }) + + export const insert = async (email: string) => { + return db.transaction(async (tx) => { + const result = await tx.insert(schema).values({ email }).returning() + const user = result[0]! + const roles = await tx + .select({ id: role.id, name: role.name }) + .from(role) + .where(eq(role.name, 'user')) + + await tx + .insert(roleToUser) + .values(roles.map((role) => ({ roleId: role.id, userId: user.id }))) + + return { ...user, roles } + }) + } + export const update = async ( id: string, partial: Partial, @@ -27,7 +67,35 @@ export namespace User { }) } - export const image = async (id: string) => { - return db.query.userImage.findFirst({ where: eq(imageSchema.userId, id) }) + export const fromEmailWithRole = async (email: string) => { + const user = await db.query.user.findFirst({ + where: eq(schema.email, email), + columns: { createdAt: false, updatedAt: false }, + with: { + roles: { + columns: {}, + with: { + role: { + columns: { + name: true, + id: true, + }, + }, + }, + }, + }, + }) + if (!user) return + return { + ...user, + roles: user.roles.map(({ role }) => ({ id: role.id, name: role.name })), + } + } + + export const imageID = async (id: string) => { + return db.query.userImage.findFirst({ + where: eq(imageSchema.userId, id), + columns: { id: true }, + }) } } diff --git a/packages/core/sst-env.d.ts b/packages/core/sst-env.d.ts index 3483413..096a535 100644 --- a/packages/core/sst-env.d.ts +++ b/packages/core/sst-env.d.ts @@ -15,6 +15,10 @@ declare module "sst" { "type": "sst.aws.Router" "url": string } + "Auth": { + "type": "sst.aws.Auth" + "url": string + } "DATABASE_URL": { "type": "sst.sst.Secret" "value": string @@ -40,6 +44,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "LambdaAuthTable": { + "name": string + "type": "sst.aws.Dynamo" + } "ReactRouter": { "type": "sst.aws.React" "url": string diff --git a/packages/functions/api/stripe.ts b/packages/functions/api/stripe.ts index b699809..678ed28 100644 --- a/packages/functions/api/stripe.ts +++ b/packages/functions/api/stripe.ts @@ -4,7 +4,6 @@ import { Stripe } from '@company/core/src/stripe' import { Subscription } from '@company/core/src/subscription/index' import { User } from '@company/core/src/user/index' import { Hono } from 'hono' -import { Resource } from 'sst' import { z } from 'zod' export const route = new Hono().post('/', async (ctx) => { @@ -12,14 +11,10 @@ export const route = new Hono().post('/', async (ctx) => { if (!sig) throw new Error(Stripe.errors.MISSING_SIGNATURE) - console.log({ - sig, - secret: Resource.StripeWebhook.secret, - id: Resource.StripeWebhook.id, - }) - const event = await Stripe.createEvent(await ctx.req.text(), sig) + console.log(event.type) + try { switch (event.type) { /** diff --git a/packages/functions/auth.ts b/packages/functions/auth.ts new file mode 100644 index 0000000..7fcb9df --- /dev/null +++ b/packages/functions/auth.ts @@ -0,0 +1,45 @@ +import { issuer } from '@openauthjs/openauth' +import { handle } from 'hono/aws-lambda' +import { DynamoStorage } from '@openauthjs/openauth/storage/dynamo' +import { Resource } from 'sst' +import { PasswordProvider } from '@openauthjs/openauth/provider/password' +import { PasswordUI } from '@openauthjs/openauth/ui/password' +import { Email } from '@company/core/src/email/index' +import { User } from '@company/core/src/user/index' +import type { Theme } from '@openauthjs/openauth/ui/theme' + +const theme: Theme = { + title: 'My company', + radius: 'md', + primary: '#1e293b', + favicon: 'https://stack.merlijn.site/favicon.ico', +} + +const app = issuer({ + theme, + storage: DynamoStorage({ + table: Resource.LambdaAuthTable.name, + }), + subjects: User.subjects, + providers: { + password: PasswordProvider( + PasswordUI({ + sendCode: async (email, code) => { + await Email.sendAuth({ email, code }) + }, + }), + ), + }, + success: async (ctx, value) => { + if (value.provider === 'password') { + let user = await User.fromEmailWithRole(value.email) + user ??= await User.insert(value.email) + if (!user) throw new Error('Unable to create user') + + return ctx.subject('user', user) + } + throw new Error('Invalid provider') + }, +}) + +export const handler = handle(app) diff --git a/packages/functions/package.json b/packages/functions/package.json index 3771915..a07cabf 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -10,8 +10,9 @@ "typecheck": "tsc" }, "dependencies": { - "sst": "^3.3.27", - "hono": "^4.6.3" + "@openauthjs/openauth": "^0.3.2", + "hono": "^4.6.3", + "sst": "^3.3.27" }, "devDependencies": { "typescript": "^5.7.2" diff --git a/packages/functions/sst-env.d.ts b/packages/functions/sst-env.d.ts index 3483413..096a535 100644 --- a/packages/functions/sst-env.d.ts +++ b/packages/functions/sst-env.d.ts @@ -15,6 +15,10 @@ declare module "sst" { "type": "sst.aws.Router" "url": string } + "Auth": { + "type": "sst.aws.Auth" + "url": string + } "DATABASE_URL": { "type": "sst.sst.Secret" "value": string @@ -40,6 +44,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "LambdaAuthTable": { + "name": string + "type": "sst.aws.Dynamo" + } "ReactRouter": { "type": "sst.aws.React" "url": string diff --git a/packages/www/app/@types/lucide.d.ts b/packages/www/app/@types/lucide.d.ts new file mode 100644 index 0000000..18e22d9 --- /dev/null +++ b/packages/www/app/@types/lucide.d.ts @@ -0,0 +1,5 @@ +declare module 'lucide-react' { + // Only show type suggestions for Lucide icons with a prefix. + // Otherwise you editor will try to import an icon instead of some component you actually want. + export * from 'lucide-react/dist/lucide-react.prefixed' +} diff --git a/packages/www/app/components/navigation.tsx b/packages/www/app/components/navigation.tsx index f5c731e..1653907 100644 --- a/packages/www/app/components/navigation.tsx +++ b/packages/www/app/components/navigation.tsx @@ -31,10 +31,11 @@ import { cn, getUserImgSrc, userHasRole } from '#app/utils/misc' type NavigationProps = { user: Awaited> + image?: { id: string } planId?: string } -export function Navigation({ user, planId }: NavigationProps) { +export function Navigation({ user, image, planId }: NavigationProps) { const navigate = useNavigate() const submit = useSubmit() const requestInfo = useRequestInfo() @@ -64,11 +65,11 @@ export function Navigation({ user, planId }: NavigationProps) { className="gap-2 px-2 data-[state=open]:bg-primary/5" >
- {user?.image?.id ? ( + {image?.id ? ( {user.username ) : ( @@ -94,11 +95,11 @@ export function Navigation({ user, planId }: NavigationProps) {
- {user?.image?.id ? ( + {image?.id ? ( {user.username ) : ( @@ -133,11 +134,11 @@ export function Navigation({ user, planId }: NavigationProps) {
- + {user ? 'Dashboard' : 'Get Started'}
diff --git a/packages/www/app/routes/admin+/_layout.tsx b/packages/www/app/routes/admin+/_layout.tsx index 80ed1e7..caa1e0d 100644 --- a/packages/www/app/routes/admin+/_layout.tsx +++ b/packages/www/app/routes/admin+/_layout.tsx @@ -7,6 +7,7 @@ import { siteConfig } from '#app/utils/constants/brand' import { buttonVariants } from '#app/components/ui/button' import { Navigation } from '#app/components/navigation' import { Subscription } from '@company/core/src/subscription/index' +import { User } from '@company/core/src/user/index' export const ROUTE_PATH = '/admin' as const @@ -16,16 +17,17 @@ export const meta: MetaFunction = () => { export async function loader({ request }: LoaderFunctionArgs) { const user = await requireUserWithRole(request, 'admin') + const image = await User.imageID(user.id) const subscription = await Subscription.fromUserID(user.id) - return { user, subscription } + return { user, image, subscription } } export default function Admin() { - const { user, subscription } = useLoaderData() + const { user, image, subscription } = useLoaderData() return (
- +
diff --git a/packages/www/app/routes/auth+/$provider.callback.tsx b/packages/www/app/routes/auth+/$provider.callback.tsx deleted file mode 100644 index c4007ed..0000000 --- a/packages/www/app/routes/auth+/$provider.callback.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { LoaderFunctionArgs } from 'react-router' -import { authenticator } from '#app/modules/auth/auth.server' -import { ROUTE_PATH as LOGIN_PATH } from '#app/routes/auth+/login' -import { ROUTE_PATH as DASHBOARD_PATH } from '#app/routes/dashboard+/_layout' - -export const ROUTE_PATH = '/auth/:provider/callback' as const - -export async function loader({ request, params }: LoaderFunctionArgs) { - if (typeof params.provider !== 'string') throw new Error('Invalid provider.') - - return authenticator.authenticate(params.provider, request, { - successRedirect: DASHBOARD_PATH, - failureRedirect: LOGIN_PATH, - }) -} diff --git a/packages/www/app/routes/auth+/$provider.tsx b/packages/www/app/routes/auth+/$provider.tsx deleted file mode 100644 index e9ebf36..0000000 --- a/packages/www/app/routes/auth+/$provider.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ActionFunctionArgs } from 'react-router' -import { redirect } from 'react-router' -import { authenticator } from '#app/modules/auth/auth.server' -import { ROUTE_PATH as LOGIN_PATH } from '#app/routes/auth+/login' - -export const ROUTE_PATH = '/auth/:provider' as const - -export async function loader() { - return redirect(LOGIN_PATH) -} - -export async function action({ request, params }: ActionFunctionArgs) { - if (typeof params.provider !== 'string') throw new Error('Invalid provider.') - return authenticator.authenticate(params.provider, request) -} diff --git a/packages/www/app/routes/auth+/_layout.tsx b/packages/www/app/routes/auth+/_layout.tsx deleted file mode 100644 index 8e78ba2..0000000 --- a/packages/www/app/routes/auth+/_layout.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { LoaderFunctionArgs } from 'react-router' -import { Link, Outlet } from 'react-router' -import { redirect } from 'react-router' -import { authenticator } from '#app/modules/auth/auth.server' -import { getDomainPathname } from '#app/utils/misc.server' -import { ROUTE_PATH as HOME_PATH } from '#app/routes/_home+/_layout' -import { ROUTE_PATH as LOGIN_PATH } from '#app/routes/auth+/login' -import { ROUTE_PATH as DASHBOARD_PATH } from '#app/routes/dashboard+/_layout' -import { Logo } from '#app/components/logo' - -export const ROUTE_PATH = '/auth' as const - -export async function loader({ request }: LoaderFunctionArgs) { - await authenticator.isAuthenticated(request, { - successRedirect: DASHBOARD_PATH, - }) - const pathname = getDomainPathname(request) - if (pathname === ROUTE_PATH) return redirect(LOGIN_PATH) - return {} -} - -export default function Layout() { - return ( -
-
- - - -
-
- - - -
-
-
- -
-
- ) -} diff --git a/packages/www/app/routes/auth+/callback.ts b/packages/www/app/routes/auth+/callback.ts new file mode 100644 index 0000000..f91d4fa --- /dev/null +++ b/packages/www/app/routes/auth+/callback.ts @@ -0,0 +1,20 @@ +import type { LoaderFunctionArgs } from '@remix-run/node' +import { redirect } from '@remix-run/node' +import { + authenticator, + getSession, + commitSession, +} from '#app/modules/auth/auth.server.ts' + +export async function loader({ request }: LoaderFunctionArgs) { + const sessionUser = await authenticator.authenticate('openauth', request) + const session = await getSession(request.headers.get('Cookie')) + + session.set('user', sessionUser) + + return redirect('/dashboard', { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }) +} diff --git a/packages/www/app/routes/auth+/login.tsx b/packages/www/app/routes/auth+/login.tsx deleted file mode 100644 index 34053d9..0000000 --- a/packages/www/app/routes/auth+/login.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import type { MetaFunction, LoaderFunctionArgs, ActionFunctionArgs } from 'react-router' -import { useRef, useEffect } from 'react' -import { Form, useLoaderData } from 'react-router' -import { data } from 'react-router' -import { useHydrated } from 'remix-utils/use-hydrated' -import { AuthenticityTokenInput } from 'remix-utils/csrf/react' -import { HoneypotInputs } from 'remix-utils/honeypot/react' -import { z } from 'zod' -import { getZodConstraint, parseWithZod } from '@conform-to/zod' -import { getFormProps, getInputProps, useForm } from '@conform-to/react' -import { LucideLoader2 } from 'lucide-react' -import { authenticator } from '#app/modules/auth/auth.server' -import { getSession, commitSession } from '#app/modules/auth/auth-session.server' -import { validateCSRF } from '#app/utils/csrf.server' -import { checkHoneypot } from '#app/utils/honeypot.server' -import { useIsPending } from '#app/utils/misc' -import { siteConfig } from '#app/utils/constants/brand' -import { Input } from '#app/components/ui/input' -import { Button } from '#app/components/ui/button' -import { ROUTE_PATH as DASHBOARD_PATH } from '#app/routes/dashboard+/_layout' -import { ROUTE_PATH as AUTH_VERIFY_PATH } from '#app/routes/auth+/verify' - -export const ROUTE_PATH = '/auth/login' as const - -export const LoginSchema = z.object({ - email: z.string().max(256).email('Email address is not valid.'), -}) - -export const meta: MetaFunction = () => { - return [{ title: `${siteConfig.siteTitle} - Login` }] -} - -export async function loader({ request }: LoaderFunctionArgs) { - await authenticator.isAuthenticated(request, { - successRedirect: DASHBOARD_PATH, - }) - - const cookie = await getSession(request.headers.get('Cookie')) - const authEmail = cookie.get('auth:email') - const authError = cookie.get(authenticator.sessionErrorKey) - - return data({ authEmail, authError } as const, { - headers: { - 'Set-Cookie': await commitSession(cookie), - }, - }) -} - -export async function action({ request }: ActionFunctionArgs) { - const url = new URL(request.url) - const pathname = url.pathname - - const clonedRequest = request.clone() - const formData = await clonedRequest.formData() - await validateCSRF(formData, clonedRequest.headers) - checkHoneypot(formData) - - await authenticator.authenticate('TOTP', request, { - successRedirect: AUTH_VERIFY_PATH, - failureRedirect: pathname, - }) -} - -export default function Login() { - const { authEmail, authError } = useLoaderData() - const inputRef = useRef(null) - const isHydrated = useHydrated() - const isPending = useIsPending() - - const [emailForm, { email }] = useForm({ - constraint: getZodConstraint(LoginSchema), - onValidate({ formData }) { - return parseWithZod(formData, { schema: LoginSchema }) - }, - }) - - useEffect(() => { - isHydrated && inputRef.current?.focus() - }, [isHydrated]) - - return ( -
-
-

- Continue to [your company] -

-

- Welcome back! Please log in to continue. -

-
- -
- {/* Security */} - - - -
- - -
- -
- {!authError && email.errors && ( - - {email.errors.join(' ')} - - )} - {!authEmail && authError && ( - - {authError.message} - - )} -
- - - - -
- - - Or continue with - -
- -
- -
- -

- By clicking continue, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy. - -

-
- ) -} diff --git a/packages/www/app/routes/auth+/logout.tsx b/packages/www/app/routes/auth+/logout.tsx index 58449b5..8ddb4dd 100644 --- a/packages/www/app/routes/auth+/logout.tsx +++ b/packages/www/app/routes/auth+/logout.tsx @@ -1,12 +1,18 @@ -import type { LoaderFunctionArgs, ActionFunctionArgs } from 'react-router' -import { authenticator } from '#app/modules/auth/auth.server' +import { destroySession, getSession } from '#app/modules/auth/auth.server.ts' +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + redirect, +} from '@remix-run/router' export const ROUTE_PATH = '/auth/logout' as const export async function loader({ request }: LoaderFunctionArgs) { - return authenticator.logout(request, { redirectTo: '/' }) + const session = await getSession(request.headers.get('Cookie')) + return redirect('/', { headers: { 'Set-Cookie': await destroySession(session) } }) } export async function action({ request }: ActionFunctionArgs) { - return authenticator.logout(request, { redirectTo: '/' }) + const session = await getSession(request.headers.get('Cookie')) + return redirect('/', { headers: { 'Set-Cookie': await destroySession(session) } }) } diff --git a/packages/www/app/routes/auth+/magic-link.tsx b/packages/www/app/routes/auth+/magic-link.tsx deleted file mode 100644 index 00bc2cd..0000000 --- a/packages/www/app/routes/auth+/magic-link.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { LoaderFunctionArgs } from 'react-router' -import { authenticator } from '#app/modules/auth/auth.server' - -import { ROUTE_PATH as DASHBOARD_PATH } from '#app/routes/dashboard+/_layout' -import { ROUTE_PATH as LOGIN_PATH } from '#app/routes/auth+/login' - -export const ROUTE_PATH = '/auth/magic-link' as const - -export async function loader({ request }: LoaderFunctionArgs) { - return authenticator.authenticate('TOTP', request, { - successRedirect: DASHBOARD_PATH, - failureRedirect: LOGIN_PATH, - }) -} diff --git a/packages/www/app/routes/auth+/verify.tsx b/packages/www/app/routes/auth+/verify.tsx deleted file mode 100644 index 46fbc7d..0000000 --- a/packages/www/app/routes/auth+/verify.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import type { MetaFunction, LoaderFunctionArgs, ActionFunctionArgs } from 'react-router' -import { useRef, useEffect } from 'react' -import { Form, useLoaderData } from 'react-router' -import { data, redirect } from 'react-router' -import { useHydrated } from 'remix-utils/use-hydrated' -import { AuthenticityTokenInput } from 'remix-utils/csrf/react' -import { HoneypotInputs } from 'remix-utils/honeypot/react' -import { z } from 'zod' -import { getZodConstraint, parseWithZod } from '@conform-to/zod' -import { getFormProps, getInputProps, useForm } from '@conform-to/react' -import { authenticator } from '#app/modules/auth/auth.server' -import { getSession, commitSession } from '#app/modules/auth/auth-session.server' -import { validateCSRF } from '#app/utils/csrf.server' -import { checkHoneypot } from '#app/utils/honeypot.server' -import { siteConfig } from '#app/utils/constants/brand' -import { ROUTE_PATH as DASHBOARD_PATH } from '#app/routes/dashboard+/_layout' -import { Input } from '#app/components/ui/input' -import { Button } from '#app/components/ui/button' - -export const ROUTE_PATH = '/auth/verify' as const - -export const VerifyLoginSchema = z.object({ - code: z.string().min(6, 'Code must be at least 6 characters.'), -}) - -export const meta: MetaFunction = () => { - return [{ title: `${siteConfig.siteTitle} - Verify` }] -} - -export async function loader({ request }: LoaderFunctionArgs) { - await authenticator.isAuthenticated(request, { - successRedirect: DASHBOARD_PATH, - }) - - const cookie = await getSession(request.headers.get('Cookie')) - const authEmail = cookie.get('auth:email') - const authError = cookie.get(authenticator.sessionErrorKey) - - if (!authEmail) return redirect('/auth/login') - - return data({ authEmail, authError } as const, { - headers: { - 'Set-Cookie': await commitSession(cookie), - }, - }) -} - -export async function action({ request }: ActionFunctionArgs) { - const url = new URL(request.url) - const pathname = url.pathname - - const clonedRequest = request.clone() - const formData = await clonedRequest.formData() - await validateCSRF(formData, clonedRequest.headers) - checkHoneypot(formData) - - await authenticator.authenticate('TOTP', request, { - successRedirect: pathname, - failureRedirect: pathname, - }) -} - -export default function Verify() { - const { authEmail, authError } = useLoaderData() - const inputRef = useRef(null) - const isHydrated = useHydrated() - - const [codeForm, { code }] = useForm({ - constraint: getZodConstraint(VerifyLoginSchema), - onValidate({ formData }) { - return parseWithZod(formData, { schema: VerifyLoginSchema }) - }, - }) - - useEffect(() => { - isHydrated && inputRef.current?.focus() - }, [isHydrated]) - - return ( -
-
-

Check your inbox!

-

- We've just emailed you a temporary password. -
- Please enter it below. -

-
- -
- - - -
- - -
- -
- {!authError && code.errors && ( - - {code.errors.join(' ')} - - )} - {authEmail && authError && ( - - {authError.message} - - )} -
- - - - - {/* Request New Code. */} - {/* Email is already in session, input it's not required. */} -
- - - -

- Did not receive the code? -

- - -
- ) -} diff --git a/packages/www/app/routes/dashboard+/_layout.tsx b/packages/www/app/routes/dashboard+/_layout.tsx index f35e797..45db732 100644 --- a/packages/www/app/routes/dashboard+/_layout.tsx +++ b/packages/www/app/routes/dashboard+/_layout.tsx @@ -5,6 +5,7 @@ import { requireUser } from '#app/modules/auth/auth.server' import { ROUTE_PATH as ONBOARDING_USERNAME_PATH } from '#app/routes/onboarding+/username' import { Navigation } from '#app/components/navigation' import { Subscription } from '@company/core/src/subscription/index' +import { User } from '@company/core/src/user/index' export const ROUTE_PATH = '/dashboard' as const @@ -12,16 +13,17 @@ export async function loader({ request }: LoaderFunctionArgs) { const user = await requireUser(request) if (!user.username) return redirect(ONBOARDING_USERNAME_PATH) const subscription = await Subscription.fromUserID(user.id) + const image = await User.imageID(user.id) - return { user, subscription } + return { user, subscription, image } } export default function Dashboard() { - const { user, subscription } = useLoaderData() + const { user, subscription, image } = useLoaderData() return (
- +
) diff --git a/packages/www/app/routes/dashboard+/checkout.tsx b/packages/www/app/routes/dashboard+/checkout.tsx index 926ef1e..26ab964 100644 --- a/packages/www/app/routes/dashboard+/checkout.tsx +++ b/packages/www/app/routes/dashboard+/checkout.tsx @@ -8,7 +8,7 @@ import { LucideAlertTriangle, LucideExternalLink, } from 'lucide-react' -import { requireSessionUser } from '#app/modules/auth/auth.server' +import { requireUser } from '#app/modules/auth/auth.server' import { PLANS } from '@company/core/src/constants' import { useInterval } from '#app/utils/hooks/use-interval' import { siteConfig } from '#app/utils/constants/brand' @@ -23,7 +23,7 @@ export const meta: MetaFunction = () => { } export async function loader({ request }: LoaderFunctionArgs) { - const sessionUser = await requireSessionUser(request) + const sessionUser = await requireUser(request) const subscription = await Subscription.fromUserID(sessionUser.id) if (!subscription) return redirect(DASHBOARD_PATH) diff --git a/packages/www/app/routes/dashboard+/settings.billing.tsx b/packages/www/app/routes/dashboard+/settings.billing.tsx index 3e32f7b..aead6c6 100644 --- a/packages/www/app/routes/dashboard+/settings.billing.tsx +++ b/packages/www/app/routes/dashboard+/settings.billing.tsx @@ -3,11 +3,10 @@ import type { Interval, Plan as PlanEnum } from '@company/core/src/constants' import { useState } from 'react' import { Form, useLoaderData } from 'react-router' import { redirect } from 'react-router' -import { requireSessionUser } from '#app/modules/auth/auth.server' +import { requireUser } from '#app/modules/auth/auth.server' import { PLANS, PRICING_PLANS, INTERVALS, CURRENCIES } from '@company/core/src/constants' import { getLocaleCurrency } from '#app/utils/misc.server' import { INTENTS } from '#app/utils/constants/misc' -import { ROUTE_PATH as LOGIN_PATH } from '#app/routes/auth+/login' import { Switch } from '#app/components/ui/switch' import { Button } from '#app/components/ui/button' import { Subscription } from '@company/core/src/subscription/index' @@ -22,9 +21,7 @@ export const meta: MetaFunction = () => { } export async function loader({ request }: LoaderFunctionArgs) { - const sessionUser = await requireSessionUser(request, { - redirectTo: LOGIN_PATH, - }) + const sessionUser = await requireUser(request) const subscription = await Subscription.fromUserID(sessionUser.id) const currency = getLocaleCurrency(request) @@ -33,9 +30,7 @@ export async function loader({ request }: LoaderFunctionArgs) { } export async function action({ request }: ActionFunctionArgs) { - const sessionUser = await requireSessionUser(request, { - redirectTo: LOGIN_PATH, - }) + const sessionUser = await requireUser(request) const formData = await request.formData() const intent = formData.get(INTENTS.INTENT) diff --git a/packages/www/app/routes/dashboard+/settings.index.tsx b/packages/www/app/routes/dashboard+/settings.index.tsx index cb91ed1..3713973 100644 --- a/packages/www/app/routes/dashboard+/settings.index.tsx +++ b/packages/www/app/routes/dashboard+/settings.index.tsx @@ -6,8 +6,7 @@ import { z } from 'zod' import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { LucideUpload } from 'lucide-react' -import { requireUser } from '#app/modules/auth/auth.server' -import { getSession, destroySession } from '#app/modules/auth/auth-session.server' +import { destroySession, getSession, requireUser } from '#app/modules/auth/auth.server' import { createToastHeaders } from '#app/utils/toast.server' import { useDoubleCheck } from '#app/utils/hooks/use-double-check' import { getUserImgSrc } from '#app/utils/misc' @@ -38,7 +37,8 @@ export const UsernameSchema = z.object({ export async function loader({ request }: LoaderFunctionArgs) { const user = await requireUser(request) - return { user } + const image = await User.imageID(user.id) + return { user, image } } export async function action({ request }: ActionFunctionArgs) { @@ -79,11 +79,10 @@ export async function action({ request }: ActionFunctionArgs) { // TODO: cancel Stripe subscription if (intent === INTENTS.USER_DELETE_ACCOUNT) { await db.delete(schema.user).where(eq(schema.user.id, user.id)) + const session = await getSession(request.headers.get('Cookie')) return redirect(HOME_PATH, { headers: { - 'Set-Cookie': await destroySession( - await getSession(request.headers.get('Cookie')), - ), + 'Set-Cookie': await destroySession(session), }, }) } @@ -92,7 +91,7 @@ export async function action({ request }: ActionFunctionArgs) { } export default function DashboardSettings() { - const { user } = useLoaderData() + const { user, image } = useLoaderData() const lastResult = useActionData() const [imageSrc, setImageSrc] = useState(null) @@ -139,9 +138,9 @@ export default function DashboardSettings() { htmlFor={avatarFields.imageFile.id} className="group relative flex cursor-pointer overflow-hidden rounded-full transition active:scale-95" > - {imageSrc || user.image?.id ? ( + {imageSrc || image?.id ? ( {user.username @@ -177,7 +176,7 @@ export default function DashboardSettings() {

Click on the avatar to upload a custom one from your files.

- {user.image?.id && !avatarFields.imageFile.errors && ( + {image?.id && !avatarFields.imageFile.errors && (