diff --git a/app/routes/action+/refresh-cache.tsx b/app/routes/action+/refresh-cache.tsx index 028e43d7b..db5ab9864 100644 --- a/app/routes/action+/refresh-cache.tsx +++ b/app/routes/action+/refresh-cache.tsx @@ -1,8 +1,8 @@ import path from 'path' import { type ActionFunctionArgs, json, redirect } from '@remix-run/node' import { cache } from '#app/utils/cache.server.ts' -import { ensurePrimary } from '#app/utils/litefs-js.server.ts' import { getPeople } from '#app/utils/credits.server.ts' +import { ensurePrimary } from '#app/utils/litefs-js.server.ts' import { getBlogMdxListItems, getMdxDirList, diff --git a/app/routes/calls+/index.js b/app/routes/calls+/index.js new file mode 100644 index 000000000..a20d99442 --- /dev/null +++ b/app/routes/calls+/index.js @@ -0,0 +1,67 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loader = loader; +exports.default = CallsIndex; +var node_1 = require("@remix-run/node"); +var transistor_server_ts_1 = require("#app/utils/transistor.server.ts"); +var calls_tsx_1 = require("../calls.tsx"); +function loader(_a) { + return __awaiter(this, arguments, void 0, function (_b) { + var episodes, seasons, seasonNumber, season; + var _c, _d; + var request = _b.request; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, (0, transistor_server_ts_1.getEpisodes)({ request: request })]; + case 1: + episodes = _e.sent(); + seasons = (0, calls_tsx_1.getEpisodesBySeason)(episodes); + seasonNumber = (_d = (_c = seasons[seasons.length - 1]) === null || _c === void 0 ? void 0 : _c.seasonNumber) !== null && _d !== void 0 ? _d : 1; + season = seasons.find(function (s) { return s.seasonNumber === seasonNumber; }); + if (!season) { + throw new Error("oh no. season for ".concat(seasonNumber)); + } + return [2 /*return*/, (0, node_1.redirect)("/calls/".concat(String(season.seasonNumber).padStart(2, '0')))]; + } + }); + }); +} +function CallsIndex() { + return
Oops... You should not see this.
; +} diff --git a/app/routes/calls_.admin+/index.js b/app/routes/calls_.admin+/index.js new file mode 100644 index 000000000..bbb019113 --- /dev/null +++ b/app/routes/calls_.admin+/index.js @@ -0,0 +1,10 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.handle = void 0; +exports.default = NoCallSelected; +exports.handle = { + getSitemapEntries: function () { return null; }, +}; +function NoCallSelected() { + return
Select a call
; +} diff --git a/app/routes/calls_.record+/index.js b/app/routes/calls_.record+/index.js new file mode 100644 index 000000000..46b84f7e5 --- /dev/null +++ b/app/routes/calls_.record+/index.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.headers = void 0; +exports.default = NoCallSelected; +var headers = function (_a) { + var parentHeaders = _a.parentHeaders; + return parentHeaders; +}; +exports.headers = headers; +function NoCallSelected() { + return
Select a call
; +} diff --git a/app/routes/chats+/index.js b/app/routes/chats+/index.js new file mode 100644 index 000000000..7d095e6e3 --- /dev/null +++ b/app/routes/chats+/index.js @@ -0,0 +1,65 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loader = loader; +exports.default = ChatsIndex; +var node_1 = require("@remix-run/node"); +var simplecast_server_ts_1 = require("#app/utils/simplecast.server.ts"); +function loader(_a) { + return __awaiter(this, arguments, void 0, function (_b) { + var seasons, seasonNumber, season; + var _c, _d; + var request = _b.request; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, (0, simplecast_server_ts_1.getSeasonListItems)({ request: request })]; + case 1: + seasons = _e.sent(); + seasonNumber = (_d = (_c = seasons[seasons.length - 1]) === null || _c === void 0 ? void 0 : _c.seasonNumber) !== null && _d !== void 0 ? _d : 1; + season = seasons.find(function (s) { return s.seasonNumber === seasonNumber; }); + if (!season) { + throw new Error("oh no. season for ".concat(seasonNumber)); + } + return [2 /*return*/, (0, node_1.redirect)("/chats/".concat(String(season.seasonNumber).padStart(2, '0')))]; + } + }); + }); +} +function ChatsIndex() { + return
Oops... You should not see this.
; +} diff --git a/app/routes/discord+/callback.tsx b/app/routes/discord+/callback.tsx index d8dccabb5..302ba0f94 100644 --- a/app/routes/discord+/callback.tsx +++ b/app/routes/discord+/callback.tsx @@ -7,8 +7,8 @@ import { PartyIcon, RefreshIcon } from '#app/components/icons.tsx' import { externalLinks } from '#app/external-links.tsx' import { tagKCDSiteSubscriber } from '#app/kit/kit.server.ts' import { type KCDHandle } from '#app/types.ts' -import { ensurePrimary } from '#app/utils/litefs-js.server.ts' import { connectDiscord } from '#app/utils/discord.server.ts' +import { ensurePrimary } from '#app/utils/litefs-js.server.ts' import { getDiscordAuthorizeURL, getDomainUrl, diff --git a/app/routes/discord+/index.js b/app/routes/discord+/index.js new file mode 100644 index 000000000..2020f0515 --- /dev/null +++ b/app/routes/discord+/index.js @@ -0,0 +1,16 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = DiscordIndex; +var button_tsx_1 = require("#app/components/button.tsx"); +var external_links_tsx_1 = require("#app/external-links.tsx"); +var misc_tsx_1 = require("#app/utils/misc.tsx"); +var use_root_data_ts_1 = require("#app/utils/use-root-data.ts"); +function DiscordIndex() { + var _a = (0, use_root_data_ts_1.useRootData)(), requestInfo = _a.requestInfo, user = _a.user; + var authorizeURL = user + ? (0, misc_tsx_1.getDiscordAuthorizeURL)(requestInfo.origin) + : external_links_tsx_1.externalLinks.discord; + return ( + Join Discord + ); +} diff --git a/app/routes/index.js b/app/routes/index.js new file mode 100644 index 000000000..9fb356c8f --- /dev/null +++ b/app/routes/index.js @@ -0,0 +1,137 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.headers = void 0; +exports.loader = loader; +exports.default = IndexRoute; +exports.ErrorBoundary = ErrorBoundary; +var node_1 = require("@remix-run/node"); +var react_1 = require("@remix-run/react"); +var button_tsx_1 = require("#app/components/button.tsx"); +var errors_tsx_1 = require("#app/components/errors.tsx"); +var about_section_tsx_1 = require("#app/components/sections/about-section.tsx"); +var blog_section_tsx_1 = require("#app/components/sections/blog-section.tsx"); +var course_section_tsx_1 = require("#app/components/sections/course-section.tsx"); +var discord_section_tsx_1 = require("#app/components/sections/discord-section.tsx"); +var hero_section_tsx_1 = require("#app/components/sections/hero-section.tsx"); +var introduction_section_tsx_1 = require("#app/components/sections/introduction-section.tsx"); +var problem_solution_section_tsx_1 = require("#app/components/sections/problem-solution-section.tsx"); +var spacer_tsx_1 = require("#app/components/spacer.tsx"); +var images_tsx_1 = require("#app/images.tsx"); +var blog_server_ts_1 = require("#app/utils/blog.server.ts"); +var blog_ts_1 = require("#app/utils/blog.ts"); +var mdx_server_ts_1 = require("#app/utils/mdx.server.ts"); +var misc_tsx_1 = require("#app/utils/misc.tsx"); +var session_server_ts_1 = require("#app/utils/session.server.ts"); +var timing_server_ts_1 = require("#app/utils/timing.server.ts"); +function loader(_a) { + return __awaiter(this, arguments, void 0, function (_b) { + var timings, _c, user, posts, totalBlogReads, blogRankings, totalBlogReaders, blogRecommendations; + var _d, _e; + var request = _b.request; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: + timings = {}; + return [4 /*yield*/, Promise.all([ + (0, session_server_ts_1.getUser)(request), + (0, mdx_server_ts_1.getBlogMdxListItems)({ request: request, timings: timings }), + (0, blog_server_ts_1.getTotalPostReads)({ request: request, timings: timings }), + (0, blog_server_ts_1.getBlogReadRankings)({ request: request, timings: timings }), + (0, blog_server_ts_1.getReaderCount)({ request: request, timings: timings }), + (0, blog_server_ts_1.getBlogRecommendations)({ request: request, timings: timings }), + ])]; + case 1: + _c = _f.sent(), user = _c[0], posts = _c[1], totalBlogReads = _c[2], blogRankings = _c[3], totalBlogReaders = _c[4], blogRecommendations = _c[5]; + return [2 /*return*/, (0, node_1.json)({ + blogRecommendations: blogRecommendations, + blogPostCount: (0, misc_tsx_1.formatNumber)(posts.length), + totalBlogReaders: totalBlogReaders < 10000 + ? 'hundreds of thousands of' + : (0, misc_tsx_1.formatNumber)(totalBlogReaders), + totalBlogReads: totalBlogReads < 100000 + ? 'hundreds of thousands of' + : (0, misc_tsx_1.formatNumber)(totalBlogReads), + currentBlogLeaderTeam: (_d = (0, blog_ts_1.getRankingLeader)(blogRankings)) === null || _d === void 0 ? void 0 : _d.team, + kodyTeam: (0, misc_tsx_1.getOptionalTeam)((_e = user === null || user === void 0 ? void 0 : user.team) !== null && _e !== void 0 ? _e : misc_tsx_1.teams[Math.floor(Math.random() * misc_tsx_1.teams.length)]), + randomImageNo: Math.random(), + }, { + headers: { + 'Cache-Control': 'private, max-age=3600', + Vary: 'Cookie', + 'Server-Timing': (0, timing_server_ts_1.getServerTimeHeader)(timings), + }, + })]; + } + }); + }); +} +exports.headers = misc_tsx_1.reuseUsefulLoaderHeaders; +function IndexRoute() { + var data = (0, react_1.useLoaderData)(); + var kodyFlying = (0, images_tsx_1.getRandomFlyingKody)(data.kodyTeam, data.randomImageNo); + return (
+ + + Read the blog + + + Take a course + +
}/> + +
+ + + + + + + + + + + +
+ ); +} +function ErrorBoundary() { + var error = (0, misc_tsx_1.useCapturedRouteError)(); + console.error(error); + return ; +} diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 1e6bb114e..b85c168b1 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -11,6 +11,7 @@ import { useLoaderData, useNavigate, useRevalidator, + useActionData, } from '@remix-run/react' import { startAuthentication } from '@simplewebauthn/browser' import { type PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server' @@ -19,15 +20,16 @@ import { AnimatePresence, motion } from 'framer-motion' import * as React from 'react' import invariant from 'tiny-invariant' import { z } from 'zod' -import { Button, LinkButton } from '#app/components/button.tsx' -import { Input, InputError, Label } from '#app/components/form-elements.tsx' -import { Grid } from '#app/components/grid.tsx' -import { PasskeyIcon } from '#app/components/icons.js' -import { HeroSection } from '#app/components/sections/hero-section.tsx' -import { Paragraph } from '#app/components/typography.tsx' -import { getGenericSocialImage, images } from '#app/images.tsx' -import { type RootLoaderType } from '#app/root.tsx' -import { getLoginInfoSession } from '#app/utils/login.server.ts' +import { Button, LinkButton } from '#app/components/button' +import { Input, InputError, Label } from '#app/components/form-elements' +import { Grid } from '#app/components/grid' +import { PasskeyIcon } from '#app/components/icons' +import { HeroSection } from '#app/components/sections/hero-section' +import { Paragraph } from '#app/components/typography' +import { getGenericSocialImage, images } from '#app/images' +import { type RootLoaderType } from '#app/root' +import { loginWithPassword, signupWithPassword } from '#app/utils/auth.server.ts' +import { getLoginInfoSession } from '#app/utils/login.server' import { getDisplayUrl, getDomainUrl, @@ -35,10 +37,13 @@ import { getOrigin, getUrl, reuseUsefulLoaderHeaders, -} from '#app/utils/misc.tsx' -import { getSocialMetas } from '#app/utils/seo.ts' -import { getUser, sendToken } from '#app/utils/session.server.ts' -import { isEmailVerified } from '#app/utils/verifier.server.ts' +} from '#app/utils/misc' +import { getSocialMetas } from '#app/utils/seo' +import { getUser, getSession } from '#app/utils/session.server' +import { validatePassword } from '#app/utils/user-validation.ts' +import { prisma } from '#app/utils/prisma.server.ts' +import { sendPasswordResetEmail } from '#app/utils/send-email.server.ts' +import { prepareVerification } from '#app/utils/verification.server.ts' export async function loader({ request }: LoaderFunctionArgs) { const user = await getUser(request) @@ -83,8 +88,14 @@ export const meta: MetaFunction = ({ export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData() const loginSession = await getLoginInfoSession(request) + const intent = formData.get('intent') const emailAddress = formData.get('email') + const password = formData.get('password') + const confirmPassword = formData.get('confirmPassword') + const firstName = formData.get('firstName') + const lastName = formData.get('lastName') + invariant(typeof emailAddress === 'string', 'Form submitted incorrectly') if (emailAddress) loginSession.setEmail(emailAddress) @@ -96,56 +107,140 @@ export async function action({ request }: ActionFunctionArgs) { }) } - // this is our honeypot. Our login is passwordless. - const failedHoneypot = Boolean(formData.get('password')) - if (failedHoneypot) { - console.info( - `FAILED HONEYPOT ON LOGIN`, - Object.fromEntries(formData.entries()), - ) - return redirect(`/login`, { - headers: await loginSession.getHeaders(), - }) - } - try { - const verifiedStatus = await isEmailVerified(emailAddress) - if (!verifiedStatus.verified) { - const errorMessage = `I tried to verify that email and got this error message: "${verifiedStatus.message}". If you think this is wrong, sign up for Kent's mailing list first (using the form on the bottom of the page) and once that's confirmed you'll be able to sign up.` - loginSession.flashError(errorMessage) - return redirect(`/login`, { - status: 400, - headers: await loginSession.getHeaders(), + if (intent === 'signin') { + if (typeof password !== 'string' || password.length === 0) { + loginSession.flashError('Password is required') + return redirect(`/login`, { + status: 400, + headers: await loginSession.getHeaders(), + }) + } + + const result = await loginWithPassword({ email: emailAddress, password }) + if (result?.user) { + const session = await getSession(request) + await session.signIn(result.user) + + const headers = new Headers() + await session.getHeaders(headers) + await loginSession.getHeaders(headers) + + return redirect('/me', { headers }) + } else { + loginSession.flashError('Invalid email or password') + return redirect(`/login`, { + status: 400, + headers: await loginSession.getHeaders(), + }) + } + } else if (intent === 'signup') { + if (typeof password !== 'string' || password.length === 0) { + loginSession.flashError('Password is required') + return redirect(`/login`, { + status: 400, + headers: await loginSession.getHeaders(), + }) + } + + if (password !== confirmPassword) { + loginSession.flashError('Passwords do not match') + return redirect(`/login`, { + status: 400, + headers: await loginSession.getHeaders(), + }) + } + + const passwordValidation = validatePassword(password) + if (!passwordValidation.isValid) { + loginSession.flashError(passwordValidation.errors[0] || 'Password is not strong enough') + return redirect(`/login`, { + status: 400, + headers: await loginSession.getHeaders(), + }) + } + + if (typeof firstName !== 'string' || !firstName) { + loginSession.flashError('First name is required') + return redirect(`/login`, { + status: 400, + headers: await loginSession.getHeaders(), + }) + } + + const result = await signupWithPassword({ + email: emailAddress, + password, + firstName, + lastName: typeof lastName === 'string' ? lastName : '', + }) + + if (result?.user) { + const session = await getSession(request) + await session.signIn(result.user) + + const headers = new Headers() + await session.getHeaders(headers) + await loginSession.getHeaders(headers) + + return redirect('/me', { headers }) + } else { + loginSession.flashError('Email address is already in use') + return redirect(`/login`, { + status: 400, + headers: await loginSession.getHeaders(), + }) + } + } else if (intent === 'forgot-password') { + // Check if user exists (but don't reveal this information) + const user = await prisma.user.findUnique({ + where: { email: emailAddress }, + select: { id: true, firstName: true }, + }) + + // Always send a "success" message to prevent user enumeration + // but only send an email if the user actually exists + if (user) { + const { verifyUrl, otp } = await prepareVerification({ + period: 600, // 10 minutes + request, + type: 'reset-password', + target: emailAddress, + }) + + await sendPasswordResetEmail({ + emailAddress, + verificationUrl: verifyUrl.toString(), + verificationCode: otp, + user, + }) + } + + return json({ + success: true, + message: `If an account with ${emailAddress} exists, we've sent a password reset email. Please check your inbox.`, }) } } catch (error: unknown) { - console.error(`There was an error verifying an email address:`, error) - // continue on... This was probably our fault... - // IDEA: notify me of this issue... - } - - try { - const domainUrl = getDomainUrl(request) - const magicLink = await sendToken({ emailAddress, domainUrl }) - loginSession.setMagicLink(magicLink) - return redirect(`/login`, { - headers: await loginSession.getHeaders(), - }) - } catch (e: unknown) { - loginSession.flashError(getErrorMessage(e)) + loginSession.flashError(getErrorMessage(error)) return redirect(`/login`, { status: 400, headers: await loginSession.getHeaders(), }) } + + return redirect('/login') } const AuthenticationOptionsSchema = z.object({ options: z.object({ challenge: z.string() }), }) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }> +type Tab = 'signin' | 'signup' | 'forgot-password' + function Login() { const data = useLoaderData() + const actionData = useActionData() const inputRef = React.useRef(null) const navigate = useNavigate() const { revalidate } = useRevalidator() @@ -154,11 +249,28 @@ function Login() { null, ) + const [activeTab, setActiveTab] = React.useState('signin') const [formValues, setFormValues] = React.useState({ email: data.email ?? '', + password: '', + confirmPassword: '', + firstName: '', + lastName: '', }) - const formIsValid = formValues.email.match(/.+@.+/) + const emailIsValid = formValues.email.match(/.+@.+/) + const passwordsMatch = formValues.password === formValues.confirmPassword + + const formIsValid = (() => { + switch (activeTab) { + case 'signin': + return emailIsValid && formValues.password + case 'signup': + return emailIsValid && formValues.password && passwordsMatch && formValues.firstName + case 'forgot-password': + return emailIsValid + } + })() async function handlePasskeyLogin() { try { @@ -206,13 +318,31 @@ function Login() { } } + const tabConfig = { + signin: { + label: 'Sign In', + description: 'Sign in to your existing account', + buttonText: 'Sign In', + }, + signup: { + label: 'Sign Up', + description: 'Create a new account', + buttonText: 'Create Account', + }, + 'forgot-password': { + label: 'Forgot Password', + description: 'Reset your password', + buttonText: 'Send Reset Email', + }, + } as const + return ( <>
@@ -245,84 +375,173 @@ function Login() {
- Or continue with email + Or continue with email and password
-
{ - const form = event.currentTarget - setFormValues({ email: form.email.value }) - }} - action="/login" - method="POST" - className="mb-10 lg:mb-12" - > -
-
- -
- - + {/* Tabs */} +
+
+
+
-
- - + {/* Success message for forgot password */} + {actionData?.success ? ( +
+
+

Check your email

+
+

{actionData.message}

+
+
+ ) : ( + { + const form = event.currentTarget + setFormValues({ + email: form.email.value, + password: form.password?.value || '', + confirmPassword: form.confirmPassword?.value || '', + firstName: form.firstName?.value || '', + lastName: form.lastName?.value || '', + }) + }} + action="/login" + method="POST" + className="mb-10 lg:mb-12" + > + + +
+ + +
-
- - { - setFormValues({ email: '' }) - inputRef.current?.focus() - }} - > - Reset - -
+ {activeTab === 'signup' && ( + <> +
+ + +
+ +
+ + +
+ + )} + + {(activeTab === 'signin' || activeTab === 'signup') && ( +
+ + +
+ )} + + {activeTab === 'signup' && ( +
+ + + {formValues.password && formValues.confirmPassword && !passwordsMatch && ( + Passwords do not match + )} +
+ )} + +
+ + { + setFormValues({ + email: '', + password: '', + confirmPassword: '', + firstName: '', + lastName: '' + }) + inputRef.current?.focus() + }} + > + Reset + +
-
- {formIsValid - ? 'Sign in form is now valid and ready to submit' - : 'Sign in form is now invalid.'} -
+
+ {formIsValid + ? `${tabConfig[activeTab].description} form is now valid and ready to submit` + : `${tabConfig[activeTab].description} form is now invalid.`} +
-
- {data.error ? ( - {data.error} - ) : data.email ? ( -

- {`✨ A magic link has been sent to ${data.email}.`} -

- ) : null} -
- +
+ {data.error ? ( + {data.error} + ) : null} +
+ + )}
@@ -356,11 +575,18 @@ function Login() { /> - {` - To sign in to your account or to create a new one fill in your - email above and we'll send you an email with a magic link to get - you started. - `} + {activeTab === 'signin' && ` + To sign in to your account, enter your email and password above. + If you don't have a password yet, click the "Forgot Password" tab to set one up. + `} + {activeTab === 'signup' && ` + Create a new account by filling out the form above. + You'll need to provide a strong password that includes uppercase, lowercase, numbers, and special characters. + `} + {activeTab === 'forgot-password' && ` + Enter your email address and we'll send you instructions to reset your password. + This will work even if you've never set a password before. + `} 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loader = loader; +exports.action = action; +var router_1 = require("@remix-run/router"); +var session_server_js_1 = require("#app/utils/session.server.js"); +var mcp_server_ts_1 = require("./mcp.server.ts"); +function loader(_a) { + return __awaiter(this, arguments, void 0, function (_b) { + var response; + var _this = this; + var _c; + var request = _b.request; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: + if ((_c = request.headers.get('accept')) === null || _c === void 0 ? void 0 : _c.includes('text/html')) { + throw (0, router_1.redirect)('/about-mcp'); + } + return [4 /*yield*/, mcp_server_ts_1.requestStorage.run(request, function () { return __awaiter(_this, void 0, void 0, function () { + var sessionId, authInfo, transport; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + sessionId = (_a = request.headers.get('mcp-session-id')) !== null && _a !== void 0 ? _a : undefined; + return [4 /*yield*/, requireAuth(request)]; + case 1: + authInfo = _b.sent(); + return [4 /*yield*/, (0, mcp_server_ts_1.connect)(sessionId)]; + case 2: + transport = _b.sent(); + return [2 /*return*/, transport.handleRequest(request, authInfo)]; + } + }); + }); })]; + case 1: + response = _d.sent(); + return [2 /*return*/, response]; + } + }); + }); +} +function action(_a) { + return __awaiter(this, arguments, void 0, function (_b) { + var response; + var _this = this; + var request = _b.request; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, mcp_server_ts_1.requestStorage.run(request, function () { return __awaiter(_this, void 0, void 0, function () { + var sessionId, authInfo, transport; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + sessionId = (_a = request.headers.get('mcp-session-id')) !== null && _a !== void 0 ? _a : undefined; + return [4 /*yield*/, requireAuth(request)]; + case 1: + authInfo = _b.sent(); + return [4 /*yield*/, (0, mcp_server_ts_1.connect)(sessionId)]; + case 2: + transport = _b.sent(); + return [2 /*return*/, transport.handleRequest(request, authInfo)]; + } + }); + }); })]; + case 1: + response = _c.sent(); + return [2 /*return*/, response]; + } + }); + }); +} +function requireAuth(request) { + return __awaiter(this, void 0, void 0, function () { + var authInfo; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, (0, session_server_js_1.getAuthInfoFromOAuthFromRequest)(request)]; + case 1: + authInfo = _a.sent(); + if (!authInfo) { + throw new Response('Unauthorized', { + status: 401, + headers: { + 'WWW-Authenticate': "Bearer error=\"unauthorized\", error_description=\"Unauthorized\"", + }, + }); + } + return [2 /*return*/, authInfo]; + } + }); + }); +} diff --git a/app/routes/onboarding.tsx b/app/routes/onboarding.tsx new file mode 100644 index 000000000..30e106adb --- /dev/null +++ b/app/routes/onboarding.tsx @@ -0,0 +1,203 @@ +import { + json, + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from '@remix-run/node' +import { Form, useActionData, useLoaderData } from '@remix-run/react' +import * as React from 'react' +import { Button } from '#app/components/button.tsx' +import { Input, InputError, Label } from '#app/components/form-elements.tsx' +import { Grid } from '#app/components/grid.tsx' +import { HeaderSection } from '#app/components/sections/header-section.tsx' +import { H2, Paragraph } from '#app/components/typography.tsx' +import { createPasswordForUser } from '#app/utils/auth.server.ts' +import { getErrorMessage, getDomainUrl } from '#app/utils/misc.tsx' +import { prisma } from '#app/utils/prisma.server.ts' +import { sendPasswordResetEmail } from '#app/utils/send-email.server.ts' +import { requireUser, getUser } from '#app/utils/session.server.ts' +import { validatePassword } from '#app/utils/user-validation.ts' +import { prepareVerification } from '#app/utils/verification.server.ts' +import { isEmailVerified } from '#app/utils/verifier.server.ts' + +export async function loader({ request }: LoaderFunctionArgs) { + return json({}) +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData() + const email = formData.get('email') + + if (typeof email !== 'string' || !email) { + return json({ error: 'Email is required' }, { status: 400 }) + } + + if (!email.match(/.+@.+/)) { + return json({ error: 'Please enter a valid email address' }, { status: 400 }) + } + + try { + // Verify email is in mailing list first + const verifiedStatus = await isEmailVerified(email) + if (!verifiedStatus.verified) { + return json( + { + error: `I tried to verify that email and got this error message: "${verifiedStatus.message}". If you think this is wrong, sign up for Kent's mailing list first (using the form on the bottom of the page) and once that's confirmed you'll be able to sign up.`, + }, + { status: 400 }, + ) + } + } catch (error: unknown) { + console.error(`There was an error verifying an email address:`, error) + // Continue on... This was probably our fault... + } + + // Check if user exists + const existingUser = await prisma.user.findUnique({ + where: { email }, + select: { id: true, firstName: true, password: { select: { hash: true } } }, + }) + + if (!existingUser) { + // For new users, send them to verification -> signup flow + try { + const { verifyUrl } = await prepareVerification({ + period: 600, // 10 minutes + request, + type: 'onboarding', + target: email, + }) + + await sendPasswordResetEmail({ + emailAddress: email, + verificationUrl: verifyUrl.toString(), + verificationCode: '000000', // Not used for onboarding + user: null, + }) + + return json({ + success: `We sent a verification email to ${email}. Click the link in the email to continue setting up your account.`, + }) + } catch (error: unknown) { + console.error('Error sending onboarding email:', error) + return json( + { error: 'Failed to send verification email. Please try again.' }, + { status: 500 }, + ) + } + } + + if (existingUser.password?.hash) { + // User already has a password, redirect to login + return json({ + error: + 'You already have an account with a password. Please use the login page to sign in.', + }) + } + + // Existing user without password - send password setup email + try { + const { verifyUrl } = await prepareVerification({ + period: 600, // 10 minutes + request, + type: 'reset-password', + target: email, + }) + + await sendPasswordResetEmail({ + emailAddress: email, + verificationUrl: verifyUrl.toString(), + verificationCode: '000000', // Not used for this flow + user: { firstName: existingUser.firstName }, + }) + + return json({ + success: `We sent a password setup email to ${email}. Click the link in the email to set up your password.`, + }) + } catch (error: unknown) { + console.error('Error sending password setup email:', error) + return json( + { error: 'Failed to send password setup email. Please try again.' }, + { status: 500 }, + ) + } +} + +export default function Onboarding() { + const actionData = useActionData() + const [email, setEmail] = React.useState('') + const emailRef = React.useRef(null) + + return ( +
+ +
+ +
+
+

What's changing?

+ + We're updating our authentication system to use passwords instead + of magic links for improved reliability. If you already have an + account, we'll help you set up a password. + + + Enter your email below and we'll send you a link to: + +
    +
  • Set up a password if you're an existing user
  • +
  • Create a new account if you're new
  • +
+
+ +
+
+ + setEmail(e.target.value)} + placeholder="Enter your email address" + autoComplete="email" + autoFocus + required + /> + {actionData && 'error' in actionData ? ( + {actionData.error} + ) : null} + {actionData && 'success' in actionData ? ( +
{actionData.success}
+ ) : null} +
+ + +
+ +
+

+ Already have a password? +

+ + If you've already set up a password, you can{' '} + + go directly to the login page + + . + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/routes/reset-password.tsx b/app/routes/reset-password.tsx new file mode 100644 index 000000000..b2a96158f --- /dev/null +++ b/app/routes/reset-password.tsx @@ -0,0 +1,222 @@ +import { + json, + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from '@remix-run/node' +import { Form, useActionData, useLoaderData } from '@remix-run/react' +import * as React from 'react' +import { Button } from '#app/components/button.tsx' +import { Input, InputError, Label } from '#app/components/form-elements.tsx' +import { Grid } from '#app/components/grid.tsx' +import { HeaderSection } from '#app/components/sections/header-section.tsx' +import { H2 } from '#app/components/typography.tsx' +import { getPasswordHash, checkIsCommonPassword } from '#app/utils/auth.server.ts' +import { getLoginInfoSession } from '#app/utils/login.server.ts' +import { getErrorMessage } from '#app/utils/misc.tsx' +import { prisma } from '#app/utils/prisma.server.ts' +import { getSession } from '#app/utils/session.server.ts' +import { getPasswordValidationMessage } from '#app/utils/user-validation.ts' +import { verifySessionStorage } from '#app/utils/verification.server.ts' + +export async function loader({ request }: LoaderFunctionArgs) { + const verifySession = await verifySessionStorage.getSession( + request.headers.get('Cookie'), + ) + const verified = verifySession.get('verified') + + if (!verified || verified.type !== 'reset-password') { + throw redirect('/login') + } + + return json({ email: verified.target }) +} + +export async function action({ request }: ActionFunctionArgs) { + const verifySession = await verifySessionStorage.getSession( + request.headers.get('Cookie'), + ) + const verified = verifySession.get('verified') + + if (!verified || verified.type !== 'reset-password') { + return json({ error: 'Invalid or expired reset session' }, { status: 400 }) + } + + const formData = await request.formData() + const password = formData.get('password') + const confirmPassword = formData.get('confirmPassword') + + if (typeof password !== 'string' || !password) { + return json({ error: 'Password is required' }, { status: 400 }) + } + if (typeof confirmPassword !== 'string' || !confirmPassword) { + return json({ error: 'Password confirmation is required' }, { status: 400 }) + } + + if (password !== confirmPassword) { + return json({ error: 'Passwords do not match' }, { status: 400 }) + } + + const passwordError = getPasswordValidationMessage(password) + if (passwordError) { + return json({ error: passwordError }, { status: 400 }) + } + + try { + // Check if password is common/compromised + const isCommon = await checkIsCommonPassword(password) + if (isCommon) { + return json( + { + error: + 'This password has been found in a data breach. Please choose a stronger password.', + }, + { status: 400 }, + ) + } + + const hashedPassword = await getPasswordHash(password) + const email = verified.target + + // Update or create password for user + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }) + + if (!user) { + return json({ error: 'User not found' }, { status: 400 }) + } + + await prisma.password.upsert({ + where: { userId: user.id }, + update: { hash: hashedPassword }, + create: { userId: user.id, hash: hashedPassword }, + }) + + // Clean up verification session and login user + const session = await getSession(request) + await session.signIn(user) + + const headers = new Headers() + await session.getHeaders(headers) + headers.append( + 'Set-Cookie', + await verifySessionStorage.destroySession(verifySession), + ) + + return redirect('/me', { headers }) + } catch (error: unknown) { + return json({ error: getErrorMessage(error) }, { status: 500 }) + } +} + +export default function ResetPassword() { + const data = useLoaderData() + const actionData = useActionData() + const passwordRef = React.useRef(null) + const confirmPasswordRef = React.useRef(null) + + const [formValues, setFormValues] = React.useState({ + password: '', + confirmPassword: '', + }) + + const passwordError = getPasswordValidationMessage(formValues.password) + const confirmPasswordError = + formValues.confirmPassword && + formValues.password !== formValues.confirmPassword + ? 'Passwords do not match' + : null + + const formIsValid = + formValues.password && + formValues.confirmPassword && + !passwordError && + !confirmPasswordError + + return ( +
+ +
+ +
+
{ + const form = e.currentTarget + setFormValues({ + password: form.password.value, + confirmPassword: form.confirmPassword.value, + }) + }} + > +
+ + + {passwordError && formValues.password ? ( + {passwordError} + ) : null} +
+ +
+ + + {confirmPasswordError ? ( + {confirmPasswordError} + ) : null} +
+ + {actionData?.error ? ( +
+ {actionData.error} +
+ ) : null} + + +
+ +
+

Password Requirements

+
    +
  • • At least 6 characters long
  • +
  • • Contains at least one uppercase letter
  • +
  • • Contains at least one lowercase letter
  • +
  • • Contains at least one number
  • +
  • • Contains at least one special character
  • +
  • • Not found in common password databases
  • +
+

+ Resetting password for: {data.email} +

+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/routes/verify.tsx b/app/routes/verify.tsx new file mode 100644 index 000000000..2467e8d89 --- /dev/null +++ b/app/routes/verify.tsx @@ -0,0 +1,177 @@ +import { + json, + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from '@remix-run/node' +import { Form, useActionData, useLoaderData } from '@remix-run/react' +import * as React from 'react' +import { Button } from '#app/components/button.tsx' +import { Input, InputError, Label } from '#app/components/form-elements.tsx' +import { Grid } from '#app/components/grid.tsx' +import { HeaderSection } from '#app/components/sections/header-section.tsx' +import { H2 } from '#app/components/typography.tsx' +import { getLoginInfoSession } from '#app/utils/login.server.ts' +import { getErrorMessage } from '#app/utils/misc.tsx' +import { getSession } from '#app/utils/session.server.ts' +import { isCodeValid, verifySessionStorage } from '#app/utils/verification.server.ts' + +const codeQueryParam = 'code' +const typeQueryParam = 'type' +const targetQueryParam = 'target' +const redirectToQueryParam = 'redirectTo' + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + const code = url.searchParams.get(codeQueryParam) + const type = url.searchParams.get(typeQueryParam) + const target = url.searchParams.get(targetQueryParam) + const redirectTo = url.searchParams.get(redirectToQueryParam) + + if (!type || !target) { + throw new Response('Invalid verification link', { status: 400 }) + } + + // Auto-verify if code is provided in URL + if (code) { + const codeIsValid = await isCodeValid({ code, type, target }) + if (codeIsValid) { + const verifySession = await verifySessionStorage.getSession() + verifySession.set('verified', { type, target }) + + if (type === 'reset-password') { + return redirect('/reset-password', { + headers: { + 'Set-Cookie': await verifySessionStorage.commitSession(verifySession), + }, + }) + } else if (type === 'onboarding') { + return redirect('/signup', { + headers: { + 'Set-Cookie': await verifySessionStorage.commitSession(verifySession), + }, + }) + } + + return redirect(redirectTo || '/me', { + headers: { + 'Set-Cookie': await verifySessionStorage.commitSession(verifySession), + }, + }) + } + } + + return json({ type, target, redirectTo }) +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData() + const code = formData.get('code') + const type = formData.get('type') + const target = formData.get('target') + const redirectTo = formData.get('redirectTo') + + if (typeof code !== 'string' || !code) { + return json({ error: 'Code is required' }, { status: 400 }) + } + if (typeof type !== 'string' || !type) { + return json({ error: 'Type is required' }, { status: 400 }) + } + if (typeof target !== 'string' || !target) { + return json({ error: 'Target is required' }, { status: 400 }) + } + + try { + const codeIsValid = await isCodeValid({ code, type, target }) + if (!codeIsValid) { + return json({ error: 'Invalid or expired code' }, { status: 400 }) + } + + const verifySession = await verifySessionStorage.getSession() + verifySession.set('verified', { type, target }) + + if (type === 'reset-password') { + return redirect('/reset-password', { + headers: { + 'Set-Cookie': await verifySessionStorage.commitSession(verifySession), + }, + }) + } else if (type === 'onboarding') { + return redirect('/signup', { + headers: { + 'Set-Cookie': await verifySessionStorage.commitSession(verifySession), + }, + }) + } + + return redirect(typeof redirectTo === 'string' ? redirectTo : '/me', { + headers: { + 'Set-Cookie': await verifySessionStorage.commitSession(verifySession), + }, + }) + } catch (error: unknown) { + return json({ error: getErrorMessage(error) }, { status: 500 }) + } +} + +export default function Verify() { + const data = useLoaderData() + const actionData = useActionData() + const codeRef = React.useRef(null) + + const [code, setCode] = React.useState('') + + return ( +
+ +
+ +
+
+ + + + +
+ + setCode(e.target.value)} + placeholder="Enter 6-digit code" + autoComplete="one-time-code" + autoFocus + required + /> + {actionData?.error ? ( + {actionData.error} + ) : null} +
+ + +
+ +
+

+ Check your email for a 6-digit verification code. +

+

+ The code was sent to {data.target} +

+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/routes/workshops+/index.js b/app/routes/workshops+/index.js new file mode 100644 index 000000000..3a180b536 --- /dev/null +++ b/app/routes/workshops+/index.js @@ -0,0 +1,153 @@ +"use strict"; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.headers = exports.meta = void 0; +var react_1 = require("@remix-run/react"); +var React = require("react"); +var grid_tsx_1 = require("#app/components/grid.tsx"); +var course_section_tsx_1 = require("#app/components/sections/course-section.tsx"); +var hero_section_tsx_1 = require("#app/components/sections/hero-section.tsx"); +var spacer_tsx_1 = require("#app/components/spacer.tsx"); +var tag_tsx_1 = require("#app/components/tag.tsx"); +var typography_tsx_1 = require("#app/components/typography.tsx"); +var workshop_card_tsx_1 = require("#app/components/workshop-card.tsx"); +var workshop_registration_panel_tsx_1 = require("#app/components/workshop-registration-panel.tsx"); +var images_tsx_1 = require("#app/images.tsx"); +var misc_tsx_1 = require("#app/utils/misc.tsx"); +var seo_ts_1 = require("#app/utils/seo.ts"); +var _workshops_tsx_1 = require("./_workshops.tsx"); +var meta = function (_a) { + var _b, _c; + var matches = _a.matches; + var requestInfo = ((_b = matches.find(function (m) { return m.id === 'root'; })) === null || _b === void 0 ? void 0 : _b.data).requestInfo; + var data = (_c = matches.find(function (m) { return m.id === 'routes/workshops+/_workshops'; })) === null || _c === void 0 ? void 0 : _c.data; + var tagsSet = new Set(); + for (var _i = 0, _d = data.workshops; _i < _d.length; _i++) { + var workshop = _d[_i]; + for (var _e = 0, _f = workshop.categories; _e < _f.length; _e++) { + var category = _f[_e]; + tagsSet.add(category); + } + } + return (0, seo_ts_1.getSocialMetas)({ + title: 'Workshops with Kent C. Dodds', + description: "Get really good at making software with Kent C. Dodds' ".concat(data.workshops.length, " workshops on ").concat((0, misc_tsx_1.listify)(__spreadArray([], tagsSet, true))), + keywords: Array.from(tagsSet).join(', '), + url: (0, misc_tsx_1.getUrl)(requestInfo), + image: (0, images_tsx_1.getSocialImageWithPreTitle)({ + url: (0, misc_tsx_1.getDisplayUrl)(requestInfo), + featuredImage: 'kent/kent-workshopping-at-underbelly', + preTitle: 'Check out these workshops', + title: "Live and remote React, TypeScript, and Testing workshops with instructor Kent C. Dodds", + }), + }); +}; +exports.meta = meta; +var headers = function (_a) { + var parentHeaders = _a.parentHeaders; + return parentHeaders; +}; +exports.headers = headers; +function WorkshopsHome() { + var data = (0, _workshops_tsx_1.useWorkshopsData)(); + var tagsSet = new Set(); + for (var _i = 0, _a = data.workshops; _i < _a.length; _i++) { + var workshop = _a[_i]; + for (var _b = 0, _c = workshop.categories; _b < _c.length; _b++) { + var category = _c[_b]; + tagsSet.add(category); + } + } + // this bit is very similar to what's on the blogs page. + // Next time we need to do work in here, let's make an abstraction for them + var tags = Array.from(tagsSet); + var searchParams = (0, react_1.useSearchParams)()[0]; + var _d = React.useState(function () { + var _a; + return (_a = searchParams.get('q')) !== null && _a !== void 0 ? _a : ''; + }), queryValue = _d[0], setQuery = _d[1]; + var workshops = queryValue + ? data.workshops.filter(function (workshop) { + return queryValue.split(' ').every(function (tag) { return workshop.categories.includes(tag); }); + }) + : data.workshops; + var visibleTags = queryValue + ? new Set(workshops.flatMap(function (workshop) { return workshop.categories; }).filter(Boolean)) + : new Set(tags); + function toggleTag(tag) { + setQuery(function (q) { + // create a regexp so that we can replace multiple occurrences (`react node react`) + var expression = new RegExp(tag, 'ig'); + var newQuery = expression.test(q) + ? q.replace(expression, '') + : "".concat(q, " ").concat(tag); + // trim and remove subsequent spaces (`react node ` => `react node`) + return newQuery.replace(/\s+/g, ' ').trim(); + }); + } + (0, misc_tsx_1.useUpdateQueryStringValueWithoutNavigation)('q', queryValue); + var workshopEvents = __spreadArray(__spreadArray([], workshops.flatMap(function (w) { return w.events; }), true), data.workshopEvents, true); + return (<> + + + {workshopEvents.length ? ( + Currently Scheduled Workshops +
+ {workshopEvents.map(function (workshopEvent, index) { return ( + + {index === workshopEvents.length - 1 ? null : ()} + ); })} +
+
) : null} + + + + +
+ {tags.map(function (tag) { return (); })} +
+
+ + + + {queryValue + ? workshops.length === 1 + ? "1 workshop found" + : "".concat(workshops.length, " workshops found") + : 'Showing all workshops'} + + +
+ + {workshops + .sort(function (a, z) { + return workshopHasEvents(a, data.workshopEvents) + ? workshopHasEvents(z, data.workshopEvents) + ? 0 + : -1 + : 1; + }) + .map(function (workshop, idx) { return (
+ +
); })} +
+
+
+ + + ); +} +function workshopHasEvents(workshop, titoEvents) { + return Boolean(workshop.events.length || + titoEvents.filter(function (e) { return e.metadata.workshopSlug === workshop.slug; }) + .length); +} +exports.default = WorkshopsHome; diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts new file mode 100644 index 000000000..0c701ba08 --- /dev/null +++ b/app/utils/auth.server.ts @@ -0,0 +1,188 @@ +import crypto from 'node:crypto' +import { type Password, type User } from '@prisma/client' +import { redirect } from '@remix-run/node' +import bcrypt from 'bcrypt' +import { createSession, prisma } from './prisma.server.ts' + +export const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30 +export const getSessionExpirationDate = () => + new Date(Date.now() + SESSION_EXPIRATION_TIME) + +export async function getPasswordHash(password: string) { + const hash = await bcrypt.hash(password, 10) + return hash +} + +export async function verifyUserPassword( + password: string, + hash: string, +) { + const isValid = await bcrypt.compare(password, hash) + return isValid +} + +export async function loginWithPassword({ + email, + password, +}: { + email: string + password: string +}) { + const user = await prisma.user.findUnique({ + where: { email }, + include: { password: { select: { hash: true } } }, + }) + + if (!user || !user.password) { + return null + } + + const isValid = await verifyUserPassword(password, user.password.hash) + if (!isValid) { + return null + } + + return { user: { id: user.id, email: user.email, firstName: user.firstName } } +} + +export async function getUserById(id: string) { + const user = await prisma.user.findUnique({ + where: { id }, + select: { id: true, email: true, firstName: true, team: true }, + }) + return user +} + +export function getPasswordHashParts(password: string) { + const hash = crypto + .createHash('sha1') + .update(password, 'utf8') + .digest('hex') + .toUpperCase() + return [hash.slice(0, 5), hash.slice(5)] as const +} + +export async function checkIsCommonPassword(password: string) { + const [prefix, suffix] = getPasswordHashParts(password) + + try { + // Use AbortController with setTimeout for compatibility + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 1000) + + const response = await fetch( + `https://api.pwnedpasswords.com/range/${prefix}`, + { signal: controller.signal }, + ) + + clearTimeout(timeoutId) + + if (!response.ok) return false + + const data = await response.text() + return data.split(/\r?\n/).some((line) => { + const [hashSuffix, ignoredPrevalenceCount] = line.split(':') + return hashSuffix === suffix + }) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + console.warn('Password check timed out') + return false + } + + console.warn('Unknown error during password check', error) + return false + } +} + +export async function signupWithPassword({ + email, + password, + firstName, + lastName, +}: { + email: string + password: string + firstName: string + lastName?: string +}) { + // Check if user already exists + const existingUser = await prisma.user.findUnique({ + where: { email: email.toLowerCase() }, + }) + + if (existingUser) { + return null + } + + const hashedPassword = await getPasswordHash(password) + + const user = await prisma.user.create({ + data: { + email: email.toLowerCase(), + firstName, + ...(lastName && { lastName }), + }, + select: { id: true, email: true, firstName: true }, + }) + + // Create password separately + await prisma.password.create({ + data: { + userId: user.id, + hash: hashedPassword, + }, + }) + + return { user } +} + +export async function signup({ + email, + password, + firstName, + team, +}: { + email: User['email'] + password: string + firstName: User['firstName'] + team: User['team'] +}) { + const hashedPassword = await getPasswordHash(password) + + const user = await prisma.user.create({ + data: { + email: email.toLowerCase(), + firstName, + team, + }, + select: { id: true, email: true, firstName: true, team: true }, + }) + + // Create password separately + await prisma.password.create({ + data: { + userId: user.id, + hash: hashedPassword, + }, + }) + + return { user } +} + +export async function createPasswordForUser({ + userId, + password, +}: { + userId: string + password: string +}) { + const hashedPassword = await getPasswordHash(password) + + await prisma.password.create({ + data: { + userId, + hash: hashedPassword, + }, + }) +} \ No newline at end of file diff --git a/app/utils/send-email.server.ts b/app/utils/send-email.server.ts index 51ee935b5..fa1b9cf7d 100644 --- a/app/utils/send-email.server.ts +++ b/app/utils/send-email.server.ts @@ -1,5 +1,5 @@ -import { getRandomFlyingKody } from '#app/images.tsx' -import { type User } from '#app/types.ts' +import { getRandomFlyingKody } from '#app/images' +import { type User } from '#app/types' import { markdownToHtmlDocument } from './markdown.server.ts' import { getOptionalTeam } from './misc.tsx' @@ -164,6 +164,82 @@ P.S. If you did not request this email, you can safely ignore it. await sendEmail(message) } +export async function sendPasswordResetEmail({ + emailAddress, + verificationUrl, + verificationCode, + user, +}: { + emailAddress: string + verificationUrl: string + verificationCode: string + user?: Pick | null +}) { + const sender = '"Kent C. Dodds" ' + + const greeting = user ? `Hi ${user.firstName}!` : 'Hi there!' + + const text = `${greeting} + +Someone (hopefully you) has requested to reset your password for kentcdodds.com. + +Here's your verification code: ${verificationCode} + +Or click this link to verify: ${verificationUrl} + +This code will expire in 10 minutes for security. + +If you didn't request this, you can safely ignore this email. + +Thanks! + +Kent C. Dodds +https://kentcdodds.com +` + + const html = ` + + + + + + +
+

Reset Your Password

+

${greeting}

+

Someone (hopefully you) has requested to reset your password for kentcdodds.com.

+ +

Your verification code:

+
${verificationCode}
+ +

Or click this button to verify:

+ Reset Password + +

This code will expire in 10 minutes for security.

+

If you didn't request this, you can safely ignore this email.

+ +

Thanks!
Kent C. Dodds

+
+ + + ` + + const message = { + from: sender, + to: emailAddress, + subject: `Reset your password for kentcdodds.com`, + text, + html, + } + + await sendEmail(message) +} + export { sendEmail, sendMagicLinkEmail } /* diff --git a/app/utils/twitter/index.js b/app/utils/twitter/index.js new file mode 100644 index 000000000..e2447074d --- /dev/null +++ b/app/utils/twitter/index.js @@ -0,0 +1,19 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./get-oembed.ts"), exports); +__exportStar(require("./get-tweet.ts"), exports); +__exportStar(require("./types/index.ts"), exports); diff --git a/app/utils/twitter/types/index.js b/app/utils/twitter/types/index.js new file mode 100644 index 000000000..17b1785a4 --- /dev/null +++ b/app/utils/twitter/types/index.js @@ -0,0 +1,23 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./edit.ts"), exports); +__exportStar(require("./entities.ts"), exports); +__exportStar(require("./media.ts"), exports); +__exportStar(require("./photo.ts"), exports); +__exportStar(require("./tweet.ts"), exports); +__exportStar(require("./user.ts"), exports); +__exportStar(require("./video.ts"), exports); diff --git a/app/utils/user-validation.ts b/app/utils/user-validation.ts new file mode 100644 index 000000000..2ef64530c --- /dev/null +++ b/app/utils/user-validation.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' + +export const PasswordSchema = z + .string({ required_error: 'Password is required' }) + .min(6, { message: 'Password is too short' }) + .max(100, { message: 'Password is too long' }) + +export const NameSchema = z + .string({ required_error: 'Name is required' }) + .min(1, { message: 'Name is required' }) + .max(40, { message: 'Name is too long' }) + .regex(/^[a-zA-Z\s'`\-\.]+$/, { + message: 'Name can only include letters, spaces, and some punctuation', + }) + +export const UsernameSchema = z + .string({ required_error: 'Username is required' }) + .min(3, { message: 'Username is too short' }) + .max(20, { message: 'Username is too long' }) + .regex(/^[a-zA-Z0-9_]+$/, { + message: 'Username can only include letters, numbers, and underscores', + }) + // users can type the username in any case, but we store it in lowercase + .transform((value) => value.toLowerCase()) + +export const EmailSchema = z + .string({ required_error: 'Email is required' }) + .email({ message: 'Email is invalid' }) + .min(3, { message: 'Email is too short' }) + .max(100, { message: 'Email is too long' }) + // users can type the email in any case, but we store it in lowercase + .transform((value) => value.toLowerCase()) + +export function getPasswordValidationMessage(password: string) { + if (!password || password.length < 6) { + return 'Password must be at least 6 characters' + } + if (password.length > 100) { + return 'Password is too long' + } + if (!/[A-Z]/.test(password)) { + return 'Password must contain at least one uppercase letter' + } + if (!/[a-z]/.test(password)) { + return 'Password must contain at least one lowercase letter' + } + if (!/[0-9]/.test(password)) { + return 'Password must contain at least one number' + } + if (!/[^a-zA-Z0-9]/.test(password)) { + return 'Password must contain at least one special character' + } + return null +} + +export function isPasswordValid(password: string): boolean { + return getPasswordValidationMessage(password) === null +} + +export const validatePassword = getPasswordValidationMessage \ No newline at end of file diff --git a/app/utils/verification.server.ts b/app/utils/verification.server.ts new file mode 100644 index 000000000..867ae8cbd --- /dev/null +++ b/app/utils/verification.server.ts @@ -0,0 +1,179 @@ +import crypto from 'node:crypto' +import { createCookieSessionStorage } from '@remix-run/node' +import { getRequiredServerEnvVar, getDomainUrl } from './misc.tsx' +import { prisma } from './prisma.server.ts' + +export const verifySessionStorage = createCookieSessionStorage({ + cookie: { + name: 'kcd_verification', + sameSite: 'lax', // CSRF protection is advised if changing to 'none' + path: '/', + httpOnly: true, + maxAge: 60 * 10, // 10 minutes + secrets: [getRequiredServerEnvVar('SESSION_SECRET')], + secure: process.env.NODE_ENV === 'production', + }, +}) + +export type VerifyFunctionArgs = { + request: Request + submission: { + intent: string + payload: Record + } + body: FormData | URLSearchParams +} + +const codeQueryParam = 'code' +const typeQueryParam = 'type' +const targetQueryParam = 'target' +const redirectToQueryParam = 'redirectTo' + +export function getRedirectToUrl({ + request, + type, + target, + redirectTo, +}: { + request: Request + type: string + target: string + redirectTo?: string +}) { + const redirectToUrl = new URL(`${getDomainUrl(request)}/verify`) + redirectToUrl.searchParams.set(typeQueryParam, type) + redirectToUrl.searchParams.set(targetQueryParam, target) + if (redirectTo) { + redirectToUrl.searchParams.set(redirectToQueryParam, redirectTo) + } + return redirectToUrl +} + +export function getVerifyUrl({ + request, + type, + target, + code, + redirectTo, +}: { + request: Request + type: string + target: string + code?: string + redirectTo?: string +}) { + const verifyUrl = getRedirectToUrl({ request, type, target, redirectTo }) + if (code) verifyUrl.searchParams.set(codeQueryParam, code) + return verifyUrl +} + +// Simple 6-digit code generation +export function generateVerificationCode(): string { + return Math.random().toString().slice(2, 8).padStart(6, '0') +} + +export async function prepareVerification({ + period = 600, // 10 minutes default + request, + type, + target, +}: { + period?: number + request: Request + type: string + target: string +}) { + const verifyUrl = getRedirectToUrl({ request, type, target }) + const redirectTo = new URL(verifyUrl.toString()) + + const code = generateVerificationCode() + const secret = crypto.randomBytes(32).toString('hex') + + const verificationData = { + type, + target, + secret: code, // Store the code directly in secret for simplicity + algorithm: 'SHA256', + digits: 6, + period, + charSet: '0123456789', + expiresAt: new Date(Date.now() + period * 1000), + } + + await prisma.verification.deleteMany({ + where: { target, type }, + }) + + await prisma.verification.create({ + data: verificationData, + }) + + // add the code to the url we'll email the user. + verifyUrl.searchParams.set(codeQueryParam, code) + + return { otp: code, redirectTo, verifyUrl } +} + +export async function isCodeValid({ + code, + type, + target, +}: { + code: string + type: string + target: string +}) { + const verification = await prisma.verification.findFirst({ + where: { + target, + type, + expiresAt: { gt: new Date() }, + }, + select: { + id: true, + secret: true, + expiresAt: true, + }, + }) + + if (!verification) return false + if (verification.expiresAt && verification.expiresAt < new Date()) return false + + // Compare the submitted code with the stored code + const isValid = verification.secret === code + + if (isValid) { + // Delete the verification after successful use + await prisma.verification.delete({ + where: { id: verification.id }, + }) + } + + return isValid +} + +export async function validateRequest( + request: Request, + body: URLSearchParams | FormData, +) { + const code = body.get(codeQueryParam) + const type = body.get(typeQueryParam) + const target = body.get(targetQueryParam) + + if (typeof code !== 'string' || !code) { + return { status: 'error', error: 'Code is required' } as const + } + if (typeof type !== 'string' || !type) { + return { status: 'error', error: 'Type is required' } as const + } + if (typeof target !== 'string' || !target) { + return { status: 'error', error: 'Target is required' } as const + } + + const codeIsValid = await isCodeValid({ code, type, target }) + if (!codeIsValid) { + return { status: 'error', error: 'Invalid code' } as const + } + + return { status: 'success', submission: { code, type, target } } as const +} \ No newline at end of file diff --git a/content/blog/aha-testing/the-spectrum-of-abstraction.js b/content/blog/aha-testing/the-spectrum-of-abstraction.js new file mode 100644 index 000000000..67c81e1a2 --- /dev/null +++ b/content/blog/aha-testing/the-spectrum-of-abstraction.js @@ -0,0 +1,76 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +function TheSpectrumOfAbstraction() { + return (
+
+
+ ANA + + (Absolutely No Abstraction) + +
+
+ AHA + + (Avoid Hasty Abstraction) + +
+
+ DRY + {"(Don't Repeat Yourself)"} +
+
+
+
+ T + h + e + + S + p + e + c + t + r + u + m + + o + f + + A + b + s + t + r + a + c + t + i + o + n +
+
); +} +exports.default = TheSpectrumOfAbstraction; diff --git a/content/blog/the-state-initializer-pattern/components.js b/content/blog/the-state-initializer-pattern/components.js new file mode 100644 index 000000000..19f3a387f --- /dev/null +++ b/content/blog/the-state-initializer-pattern/components.js @@ -0,0 +1,68 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SimpleCounter = SimpleCounter; +exports.InitialCounterAlmostThere = InitialCounterAlmostThere; +exports.BugReproduced = BugReproduced; +exports.FinishedCounter = FinishedCounter; +exports.KeyPropReset = KeyPropReset; +var React = require("react"); +function SimpleCounter() { + var _a = React.useState(0), count = _a[0], setCount = _a[1]; + var increment = function () { return setCount(function (c) { return c + 1; }); }; + var reset = function () { return setCount(0); }; + return ; +} +function InitialCounterAlmostThere(_a) { + var _b = _a.initialCount, initialCount = _b === void 0 ? 0 : _b; + var _c = React.useState(initialCount), count = _c[0], setCount = _c[1]; + var increment = function () { return setCount(function (c) { return c + 1; }); }; + var reset = function () { return setCount(initialCount); }; + return ; +} +function BugReproduced() { + var _a = React.useState(3), initialCount = _a[0], setInitialCount = _a[1]; + React.useEffect(function () { + var interval = setInterval(function () { + setInitialCount(Math.floor(Math.random() * 50)); + }, 500); + return function () { + clearInterval(interval); + }; + }, []); + return ; +} +function FinishedCounter(_a) { + var _b = _a.initialCount, initialCount = _b === void 0 ? 0 : _b; + var initialState = React.useRef({ count: initialCount }).current; + var _c = React.useState(initialState.count), count = _c[0], setCount = _c[1]; + var increment = function () { return setCount(function (c) { return c + 1; }); }; + var reset = function () { return setCount(initialState.count); }; + return ; +} +function KeyPropReset() { + var _a = React.useState(0), key = _a[0], setKey = _a[1]; + var resetCounter = function () { return setKey(function (k) { return k + 1; }); }; + return ; +} +function KeyPropResetCounter(_a) { + var reset = _a.reset; + var _b = React.useState(0), count = _b[0], setCount = _b[1]; + var increment = function () { return setCount(function (c) { return c + 1; }); }; + return ; +} +function CountUI(_a) { + var count = _a.count, increment = _a.increment, reset = _a.reset; + return (
+ + +
); +} diff --git a/content/blog/usememo-and-usecallback/components.js b/content/blog/usememo-and-usecallback/components.js new file mode 100644 index 000000000..eb3b4d9c6 --- /dev/null +++ b/content/blog/usememo-and-usecallback/components.js @@ -0,0 +1,41 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CandyDispenser = CandyDispenser; +exports.Poll = Poll; +var React = require("react"); +function CandyDispenser() { + var initialCandies = ['snickers', 'skittles', 'twix', 'milky way']; + var _a = React.useState(initialCandies), candies = _a[0], setCandies = _a[1]; + function dispense(candy) { + setCandies(function (allCandies) { return allCandies.filter(function (c) { return c !== candy; }); }); + } + return (
+

Candy Dispenser

+
+
Available Candy
+ {candies.length === 0 ? () : (
    + {candies.map(function (candy) { return (
  • + {candy} +
  • ); })} +
)} +
+
); +} +function Poll() { + var _a = React.useState(null), answer = _a[0], setAnswer = _a[1]; + var isWrong = answer === 'useCallback'; + var isRight = answer === 'original'; + return (
+ {isRight ? (
You are correct! 🥳
) : (
+
+ +
+
+ +
+ {answer === 'useCallback' ? (
Sorry, wrong answer. Try again
) : null} +
)} +
); +} diff --git a/e2e/password-auth.spec.ts b/e2e/password-auth.spec.ts new file mode 100644 index 000000000..dd2244fb9 --- /dev/null +++ b/e2e/password-auth.spec.ts @@ -0,0 +1,174 @@ +import { test, expect } from '@playwright/test' +import { readEmail } from '#tests/mocks/utils.ts' +import { createUser } from '#tests/playwright-utils.ts' + +test.describe('Password Authentication - Tabbed Interface', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login') + }) + + test('should display three tabs: Sign In, Sign Up, and Forgot Password', async ({ page }) => { + await expect(page.locator('button:has-text("Sign In")')).toBeVisible() + await expect(page.locator('button:has-text("Sign Up")')).toBeVisible() + await expect(page.locator('button:has-text("Forgot Password")')).toBeVisible() + }) + + test('should default to Sign In tab', async ({ page }) => { + // Sign In tab should be active by default + await expect(page.locator('button:has-text("Sign In")').first()).toHaveClass(/border-blue-500/) + await expect(page.locator('button[type="submit"]:has-text("Sign In")')).toBeVisible() + }) + + test('should switch between tabs and show appropriate forms', async ({ page }) => { + // Sign In tab (default) + await expect(page.locator('[name="email"]')).toBeVisible() + await expect(page.locator('[name="password"]')).toBeVisible() + await expect(page.locator('[name="firstName"]')).not.toBeVisible() + + // Switch to Sign Up tab + await page.locator('button:has-text("Sign Up")').first().click() + await expect(page.locator('[name="email"]')).toBeVisible() + await expect(page.locator('[name="firstName"]')).toBeVisible() + await expect(page.locator('[name="lastName"]')).toBeVisible() + await expect(page.locator('[name="password"]')).toBeVisible() + await expect(page.locator('[name="confirmPassword"]')).toBeVisible() + await expect(page.locator('button[type="submit"]:has-text("Create Account")')).toBeVisible() + + // Switch to Forgot Password tab + await page.locator('button:has-text("Forgot Password")').first().click() + await expect(page.locator('[name="email"]')).toBeVisible() + await expect(page.locator('[name="password"]')).not.toBeVisible() + await expect(page.locator('button[type="submit"]:has-text("Send Reset Email")')).toBeVisible() + }) + + test('should validate sign in form', async ({ page }) => { + // Submit button should be disabled without valid inputs + await expect(page.locator('button[type="submit"]:has-text("Sign In")')).toBeDisabled() + + // Fill in email + await page.fill('[name="email"]', 'test@example.com') + await expect(page.locator('button[type="submit"]:has-text("Sign In")')).toBeDisabled() + + // Fill in password + await page.fill('[name="password"]', 'password123') + await expect(page.locator('button[type="submit"]:has-text("Sign In")')).toBeEnabled() + }) + + test('should show error for missing password in sign in', async ({ page }) => { + await page.fill('[name="email"]', 'test@example.com') + await page.locator('button[type="submit"]:has-text("Sign In")').click() + + await expect(page).toHaveURL('/login') + await expect(page.locator('[id="error-message"]')).toContainText('Password is required') + }) + + test('should show error for wrong password', async ({ page }) => { + const userData = createUser() + + await page.fill('[name="email"]', userData.email) + await page.fill('[name="password"]', 'wrongpassword') + await page.locator('button[type="submit"]:has-text("Sign In")').click() + + await expect(page).toHaveURL('/login') + await expect(page.locator('[id="error-message"]')).toContainText('Invalid email or password') + }) + + test('should validate sign up form', async ({ page }) => { + await page.locator('button:has-text("Sign Up")').first().click() + + // Submit button should be disabled without valid inputs + await expect(page.locator('button[type="submit"]:has-text("Create Account")')).toBeDisabled() + + // Fill in required fields + await page.fill('[name="email"]', 'test@example.com') + await page.fill('[name="firstName"]', 'John') + await page.fill('[name="password"]', 'Password123!') + await page.fill('[name="confirmPassword"]', 'Password123!') + + await expect(page.locator('button[type="submit"]:has-text("Create Account")')).toBeEnabled() + }) + + test('should show password mismatch error', async ({ page }) => { + await page.locator('button:has-text("Sign Up")').first().click() + + await page.fill('[name="password"]', 'password1') + await page.fill('[name="confirmPassword"]', 'password2') + + await expect(page.locator('text=Passwords do not match')).toBeVisible() + }) + + test('should validate forgot password form', async ({ page }) => { + await page.locator('button:has-text("Forgot Password")').first().click() + + // Submit button should be disabled without email + await expect(page.locator('button[type="submit"]:has-text("Send Reset Email")')).toBeDisabled() + + // Fill in email + await page.fill('[name="email"]', 'test@example.com') + await expect(page.locator('button[type="submit"]:has-text("Send Reset Email")')).toBeEnabled() + }) + + test('should handle forgot password submission', async ({ page }) => { + await page.locator('button:has-text("Forgot Password")').first().click() + + await page.fill('[name="email"]', 'test@example.com') + await page.locator('button[type="submit"]:has-text("Send Reset Email")').click() + + await expect(page.locator('text=Check your email')).toBeVisible() + await expect(page.locator('text=If an account with test@example.com exists')).toBeVisible() + }) + + test('should show passkey login option', async ({ page }) => { + await expect(page.locator('button:has-text("Login with Passkey")')).toBeVisible() + }) + + test('should handle form reset', async ({ page }) => { + await page.fill('[name="email"]', 'test@example.com') + await page.fill('[name="password"]', 'password123') + + await page.locator('button:has-text("Reset")').click() + + await expect(page.locator('[name="email"]')).toHaveValue('') + await expect(page.locator('[name="password"]')).toHaveValue('') + }) + + test('should be accessible with proper ARIA labels', async ({ page }) => { + // Check tab accessibility + await expect(page.locator('nav[aria-label="Tabs"]')).toBeVisible() + + // Check form labels + await expect(page.locator('label[for="email-address"]')).toBeVisible() + await expect(page.locator('label[for="password"]')).toBeVisible() + + // Test keyboard navigation between tabs + await page.locator('button:has-text("Sign In")').first().focus() + await page.keyboard.press('ArrowRight') + await expect(page.locator('button:has-text("Sign Up")').first()).toBeFocused() + }) + + test('should maintain context-appropriate descriptions', async ({ page }) => { + // Sign In tab + await expect(page.locator('text=To sign in to your account, enter your email and password above')).toBeVisible() + + // Sign Up tab + await page.locator('button:has-text("Sign Up")').first().click() + await expect(page.locator('text=Create a new account by filling out the form above')).toBeVisible() + + // Forgot Password tab + await page.locator('button:has-text("Forgot Password")').first().click() + await expect(page.locator('text=Enter your email address and we\'ll send you instructions to reset your password')).toBeVisible() + }) + + test('should have proper intent values for different forms', async ({ page }) => { + // Sign In intent + await expect(page.locator('input[name="intent"][value="signin"]')).toBeVisible() + + // Sign Up intent + await page.locator('button:has-text("Sign Up")').first().click() + await expect(page.locator('input[name="intent"][value="signup"]')).toBeVisible() + + // Forgot Password intent + await page.locator('button:has-text("Forgot Password")').first().click() + await expect(page.locator('input[name="intent"][value="forgot-password"]')).toBeVisible() + }) +}) \ No newline at end of file diff --git a/mocks/index.js b/mocks/index.js new file mode 100644 index 000000000..08e6e1ac6 --- /dev/null +++ b/mocks/index.js @@ -0,0 +1,159 @@ +"use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +var msw_1 = require("msw"); +var node_1 = require("msw/node"); +var discord_ts_1 = require("./discord.ts"); +var github_ts_1 = require("./github.ts"); +var kit_ts_1 = require("./kit.ts"); +var oauth_ts_1 = require("./oauth.ts"); +var oembed_ts_1 = require("./oembed.ts"); +var simplecast_ts_1 = require("./simplecast.ts"); +var tito_ts_1 = require("./tito.ts"); +var transistor_ts_1 = require("./transistor.ts"); +var twitter_ts_1 = require("./twitter.ts"); +var utils_ts_1 = require("./utils.ts"); +var remix = process.env.REMIX_DEV_HTTP_ORIGIN; +// put one-off handlers that don't really need an entire file to themselves here +var miscHandlers = [ + msw_1.http.post("".concat(remix, "/ping"), function () { + return (0, msw_1.passthrough)(); + }), + msw_1.http.get('https://res.cloudinary.com/kentcdodds-com/image/upload/w_100,q_auto,f_webp,e_blur:1000/unsplash/:photoId', function () { return __awaiter(void 0, void 0, void 0, function () { + var base64, buffer; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, (0, utils_ts_1.isConnectedToTheInternet)()]; + case 1: + if (_a.sent()) + return [2 /*return*/, (0, msw_1.passthrough)()]; + base64 = 'UklGRhoBAABXRUJQVlA4IA4BAABwCgCdASpkAEMAPqVInUq5sy+hqvqpuzAUiWcG+BsvrZQel/iYPLGE154ZiYwzeF8UJRAKZ0oAzLdTpjlp8qBuGwW1ntMTe6iQZbxzyP4gBeg7X7SH7NwyBcUDAAD+8MrTwbAD8OLmsoaL1QDPwEE+GrfqLQPn6xkgFHCB8lyjV3K2RvcQ7pSvgA87LOVuDtMrtkm+tTV0x1RcIe4Uvb6J+yygkV48DSejuyrMWrYgoZyjkf/0/L9+bAZgCam6+oHqjBSWTq5jF7wzBxYwfoGY7OdYZOdeGb4euuuLaCzDHz/QRbDCaIsJWJW3Jo4bkbz44AI/8UfFTGX4tMTRcKLXTDIviU+/u7UnlVaDQAA='; + buffer = Buffer.from(base64); + return [2 /*return*/, msw_1.HttpResponse.json(buffer)]; + } + }); + }); }), + msw_1.http.get(/res.cloudinary.com\/kentcdodds-com\//, function () { + return (0, msw_1.passthrough)(); + }), + msw_1.http.post('https://api.mailgun.net/v3/:domain/messages', function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) { + var reqBody, body, fixture, randomId, id; + var _c; + var request = _b.request, params = _b.params; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: return [4 /*yield*/, request.text()]; + case 1: + reqBody = _d.sent(); + body = Object.fromEntries(new URLSearchParams(reqBody)); + console.info('🔶 mocked email contents:', body); + if (!(body.text && body.to)) return [3 /*break*/, 4]; + return [4 /*yield*/, (0, utils_ts_1.readFixture)()]; + case 2: + fixture = _d.sent(); + return [4 /*yield*/, (0, utils_ts_1.updateFixture)({ + email: __assign(__assign({}, fixture.email), (_c = {}, _c[body.to] = body, _c)), + })]; + case 3: + _d.sent(); + _d.label = 4; + case 4: + randomId = '20210321210543.1.E01B8B612C44B41B'; + id = "<".concat(randomId, ">@").concat(params.domain); + return [2 /*return*/, msw_1.HttpResponse.json({ id: id, message: 'Queued. Thank you.' })]; + } + }); + }); }), + msw_1.http.head('https://www.gravatar.com/avatar/:md5Hash', function () { return __awaiter(void 0, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, (0, utils_ts_1.isConnectedToTheInternet)()]; + case 1: + if (_a.sent()) + return [2 /*return*/, (0, msw_1.passthrough)()]; + return [2 /*return*/, msw_1.HttpResponse.json(null, { status: 404 })]; + } + }); + }); }), + msw_1.http.get(/http:\/\/localhost:\d+\/.*/, function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) { + return [2 /*return*/, (0, msw_1.passthrough)()]; + }); }); }), + msw_1.http.post(/http:\/\/localhost:\d+\/.*/, function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) { + return [2 /*return*/, (0, msw_1.passthrough)()]; + }); }); }), + msw_1.http.get('https://verifyright.co/verify/:email', function () { + return msw_1.HttpResponse.json({ status: true }); + }), +]; +var server = node_1.setupServer.apply(void 0, __spreadArray(__spreadArray(__spreadArray(__spreadArray(__spreadArray(__spreadArray(__spreadArray(__spreadArray(__spreadArray(__spreadArray([], github_ts_1.githubHandlers, false), oauth_ts_1.oauthHandlers, false), oembed_ts_1.oembedHandlers, false), twitter_ts_1.twitterHandlers, false), tito_ts_1.tiToHandlers, false), transistor_ts_1.transistorHandlers, false), discord_ts_1.discordHandlers, false), kit_ts_1.kitHandlers, false), simplecast_ts_1.simplecastHandlers, false), miscHandlers, false)); +server.listen({ + onUnhandledRequest: function (request, print) { + // Do not print warnings on unhandled requests to https://<:userId>.ingest.us.sentry.io/api/ + // Note: a request handler with passthrough is not suited with this type of url + // until there is a more permissible url catching system + // like requested at https://github.com/mswjs/msw/issues/1804 + if (request.url.includes('.sentry.io')) { + return; + } + // Print the regular MSW unhandled request warning otherwise. + print.warning(); + }, +}); +console.info('🔶 Mock server installed'); +process.once('SIGINT', function () { return server.close(); }); +process.once('SIGTERM', function () { return server.close(); }); diff --git a/other/build-server.js b/other/build-server.js new file mode 100644 index 000000000..87260f5da --- /dev/null +++ b/other/build-server.js @@ -0,0 +1,66 @@ +"use strict"; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +var path_1 = require("path"); +var url_1 = require("url"); +var esbuild_1 = require("esbuild"); +var fs_extra_1 = require("fs-extra"); +var glob_1 = require("glob"); +var pkg = fs_extra_1.default.readJsonSync(path_1.default.join(process.cwd(), 'package.json')); +var __dirname = path_1.default.dirname((0, url_1.fileURLToPath)(import.meta.url)); +var globsafe = function (s) { return s.replace(/\\/g, '/'); }; +var here = function () { + var s = []; + for (var _i = 0; _i < arguments.length; _i++) { + s[_i] = arguments[_i]; + } + return globsafe(path_1.default.join.apply(path_1.default, __spreadArray([__dirname], s, false))); +}; +var allFiles = (0, glob_1.globSync)(here('../server/**/*.*'), { + ignore: [ + 'server/dev-server.js', // for development only + 'server/content-watcher.ts', // for development only + '**/tsconfig.json', + '**/eslint*', + '**/__tests__/**', + ], +}); +var entries = []; +var outdir = here('../server-build'); +for (var _i = 0, allFiles_1 = allFiles; _i < allFiles_1.length; _i++) { + var file = allFiles_1[_i]; + if (/\.(ts|js|tsx|jsx)$/.test(file)) { + entries.push(file); + } + else { + var filename = path_1.default.basename(file); + var dest = path_1.default.join(outdir, filename); + fs_extra_1.default.ensureDirSync(outdir); + fs_extra_1.default.copySync(file, dest); + console.log("copied: ".concat(filename)); + } +} +console.log(); +console.log('building...'); +esbuild_1.default + .build({ + entryPoints: entries, + outdir: outdir, + target: ["node".concat(pkg.engines.node)], + platform: 'node', + sourcemap: true, + format: 'esm', + logLevel: 'info', +}) + .catch(function (error) { + console.error(error); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index 9ac1a80e3..b5001c96a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@modelcontextprotocol/sdk": "^1.17.0", "@octokit/plugin-throttling": "^9.3.0", "@octokit/rest": "^20.1.1", - "@prisma/client": "^5.14.0", + "@prisma/client": "^5.22.0", "@reach/accordion": "^0.18.0", "@reach/auto-id": "^0.18.0", "@reach/checkbox": "^0.18.0", @@ -132,6 +132,7 @@ "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "^15.0.7", "@testing-library/user-event": "^14.5.2", + "@types/bcrypt": "^6.0.0", "@types/compression": "^1.7.5", "@types/cookie-session": "^2.0.49", "@types/dom-mediacapture-record": "^1.0.19", @@ -9212,6 +9213,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", diff --git a/package.json b/package.json index 64a267be2..bc35eb8b3 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@modelcontextprotocol/sdk": "^1.17.0", "@octokit/plugin-throttling": "^9.3.0", "@octokit/rest": "^20.1.1", - "@prisma/client": "^5.14.0", + "@prisma/client": "^5.22.0", "@reach/accordion": "^0.18.0", "@reach/auto-id": "^0.18.0", "@reach/checkbox": "^0.18.0", @@ -174,6 +174,7 @@ "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "^15.0.7", "@testing-library/user-event": "^14.5.2", + "@types/bcrypt": "^6.0.0", "@types/compression": "^1.7.5", "@types/cookie-session": "^2.0.49", "@types/dom-mediacapture-record": "^1.0.19", diff --git a/prisma/migrations/20250919215146_add_password_auth/migration.sql b/prisma/migrations/20250919215146_add_password_auth/migration.sql new file mode 100644 index 000000000..5ee0de9b3 --- /dev/null +++ b/prisma/migrations/20250919215146_add_password_auth/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "Password" ( + "id" TEXT NOT NULL PRIMARY KEY, + "hash" TEXT NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Verification" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "type" TEXT NOT NULL, + "target" TEXT NOT NULL, + "secret" TEXT NOT NULL, + "algorithm" TEXT NOT NULL, + "digits" INTEGER NOT NULL, + "period" INTEGER NOT NULL, + "charSet" TEXT NOT NULL, + "expiresAt" DATETIME +); + +-- CreateIndex +CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId"); + +-- CreateIndex +CREATE INDEX "Password_userId_idx" ON "Password"("userId"); + +-- CreateIndex +CREATE INDEX "Verification_target_type_idx" ON "Verification"("target", "type"); + +-- CreateIndex +CREATE INDEX "Verification_expiresAt_idx" ON "Verification"("expiresAt"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index e5e5c4705..2a5a44419 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "sqlite" \ No newline at end of file +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 027e55265..3b56794c7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { calls Call[] sessions Session[] postReads PostRead[] + password Password? @@index([team]) } @@ -85,3 +86,28 @@ model Passkey { @@index(userId) } + +model Password { + id String @id @default(uuid()) + hash String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String @unique + + @@index(userId) +} + +model Verification { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + type String // 'reset-password', 'onboarding' + target String // email address + secret String + algorithm String + digits Int + period Int + charSet String + expiresAt DateTime? + + @@index([target, type]) + @@index([expiresAt]) +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 000000000..fe106c4bf --- /dev/null +++ b/server/index.js @@ -0,0 +1,402 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +var _a; +Object.defineProperty(exports, "__esModule", { value: true }); +var crypto_1 = require("crypto"); +var fs_1 = require("fs"); +var path_1 = require("path"); +var url_1 = require("url"); +var express_1 = require("@remix-run/express"); +var node_1 = require("@remix-run/node"); +var remix_1 = require("@sentry/remix"); +var address_1 = require("address"); +var chalk_1 = require("chalk"); +var close_with_grace_1 = require("close-with-grace"); +var compression_1 = require("compression"); +var express_2 = require("express"); +require("express-async-errors"); +var get_port_1 = require("get-port"); +var helmet_1 = require("helmet"); +var morgan_1 = require("morgan"); +var on_finished_1 = require("on-finished"); +var server_timing_1 = require("server-timing"); +var source_map_support_1 = require("source-map-support"); +var litefs_js_server_ts_1 = require("../app/utils/litefs-js.server.ts"); +var redirects_js_1 = require("./redirects.js"); +source_map_support_1.default.install(); +(0, node_1.installGlobals)(); +var viteDevServer = process.env.NODE_ENV === 'production' + ? undefined + : await Promise.resolve().then(function () { return require('vite'); }).then(function (vite) { + return vite.createServer({ + server: { middlewareMode: true }, + }); + }); +var getBuild = function () { return __awaiter(void 0, void 0, void 0, function () { + return __generator(this, function (_a) { + if (viteDevServer) { + return [2 /*return*/, viteDevServer.ssrLoadModule('virtual:remix/server-build')]; + } + // @ts-ignore (this file may or may not exist yet) + return [2 /*return*/, Promise.resolve().then(function () { return require('../build/server/index.js'); })]; + }); +}); }; +var __dirname = path_1.default.dirname((0, url_1.fileURLToPath)(import.meta.url)); +var here = function () { + var d = []; + for (var _i = 0; _i < arguments.length; _i++) { + d[_i] = arguments[_i]; + } + return path_1.default.join.apply(path_1.default, __spreadArray([__dirname], d, false)); +}; +var primaryHost = 'kentcdodds.com'; +var getHost = function (req) { var _a, _b; return (_b = (_a = req.get('X-Forwarded-Host')) !== null && _a !== void 0 ? _a : req.get('host')) !== null && _b !== void 0 ? _b : ''; }; +var MODE = process.env.NODE_ENV; +if (MODE === 'production' && process.env.SENTRY_DSN) { + void Promise.resolve().then(function () { return require('./utils/monitoring.js'); }).then(function (_a) { + var init = _a.init; + return init(); + }); +} +if (MODE === 'production') { + (0, remix_1.init)({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 0.3, + environment: process.env.NODE_ENV, + }); + (0, remix_1.setContext)('region', { name: (_a = process.env.FLY_INSTANCE) !== null && _a !== void 0 ? _a : 'unknown' }); +} +var app = (0, express_2.default)(); +app.use((0, server_timing_1.default)()); +app.get('/img/social', redirects_js_1.oldImgSocial); +// TODO: remove this once all clients are updated +app.post('/__metronome', function (req, res) { + res.status(503); + return res.send('Metronome is deprecated and no longer in use.'); +}); +app.use(function (req, res, next) { return __awaiter(void 0, void 0, void 0, function () { + var _a, currentInstance, primaryInstance, proto, host; + var _b, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, (0, litefs_js_server_ts_1.getInstanceInfo)()]; + case 1: + _a = _e.sent(), currentInstance = _a.currentInstance, primaryInstance = _a.primaryInstance; + res.set('X-Powered-By', 'Kody the Koala'); + res.set('X-Fly-Region', (_b = process.env.FLY_REGION) !== null && _b !== void 0 ? _b : 'unknown'); + res.set('X-Fly-App', (_c = process.env.FLY_APP_NAME) !== null && _c !== void 0 ? _c : 'unknown'); + res.set('X-Fly-Instance', currentInstance); + res.set('X-Fly-Primary-Instance', primaryInstance); + res.set('X-Frame-Options', 'SAMEORIGIN'); + proto = (_d = req.get('X-Forwarded-Proto')) !== null && _d !== void 0 ? _d : req.protocol; + host = getHost(req); + if (!host.endsWith(primaryHost)) { + res.set('X-Robots-Tag', 'noindex'); + } + res.set('Access-Control-Allow-Origin', "".concat(proto, "://").concat(host)); + // if they connect once with HTTPS, then they'll connect with HTTPS for the next hundred years + res.set('Strict-Transport-Security', "max-age=".concat(60 * 60 * 24 * 365 * 100)); + next(); + return [2 /*return*/]; + } + }); +}); }); +app.use(function (req, res, next) { + var proto = req.get('X-Forwarded-Proto'); + var host = getHost(req); + if (proto === 'http') { + res.set('X-Forwarded-Proto', 'https'); + res.redirect("https://".concat(host).concat(req.originalUrl)); + return; + } + next(); +}); +app.all('*', (0, redirects_js_1.getRedirectsMiddleware)({ + redirectsString: fs_1.default.readFileSync(here('./_redirects.txt'), 'utf8'), +})); +app.use(function (req, res, next) { + if (req.path.endsWith('/') && req.path.length > 1) { + var query = req.url.slice(req.path.length); + var safepath = req.path.slice(0, -1).replace(/\/+/g, '/'); + res.redirect(301, safepath + query); + } + else { + next(); + } +}); +app.use((0, compression_1.default)()); +var publicAbsolutePath = here('../build/client'); +if (viteDevServer) { + app.use(viteDevServer.middlewares); +} +else { + app.use(express_2.default.static(publicAbsolutePath, { + maxAge: '1w', + setHeaders: function (res, resourcePath) { + var relativePath = resourcePath.replace("".concat(publicAbsolutePath, "/"), ''); + if (relativePath.startsWith('build/info.json')) { + res.setHeader('cache-control', 'no-cache'); + return; + } + // If we ever change our font (which we quite possibly never will) + // then we'll just want to change the filename or something... + // Remix fingerprints its assets so we can cache forever + if (relativePath.startsWith('fonts') || + relativePath.startsWith('build')) { + res.setHeader('cache-control', 'public, max-age=31536000, immutable'); + } + }, + })); +} +app.get(['/build/*', '/images/*', '/fonts/*', '/favicons/*'], function (req, res) { + // if we made it past the express.static for /build, then we're missing something. No bueno. + return res.status(404).send('Not found'); +}); +// log the referrer for 404s +app.use(function (req, res, next) { + (0, on_finished_1.default)(res, function () { + var referrer = req.get('referer'); + if (res.statusCode === 404 && referrer) { + console.info("\uD83D\uDC7B 404 on ".concat(req.method, " ").concat(req.path, " referred by: ").concat(referrer)); + } + }); + next(); +}); +app.use((0, morgan_1.default)(function (tokens, req, res) { + var _a, _b, _c, _d, _e, _f; + try { + var host = getHost(req); + return [ + (_a = tokens.method) === null || _a === void 0 ? void 0 : _a.call(tokens, req, res), + "".concat(host).concat(decodeURIComponent((_c = (_b = tokens.url) === null || _b === void 0 ? void 0 : _b.call(tokens, req, res)) !== null && _c !== void 0 ? _c : '')), + (_d = tokens.status) === null || _d === void 0 ? void 0 : _d.call(tokens, req, res), + (_e = tokens.res) === null || _e === void 0 ? void 0 : _e.call(tokens, req, res, 'content-length'), + '-', + (_f = tokens['response-time']) === null || _f === void 0 ? void 0 : _f.call(tokens, req, res), + 'ms', + ].join(' '); + } + catch (error) { + console.error("Error generating morgan log line", error, req.originalUrl); + return ''; + } +}, { + skip: function (req, res) { + if (res.statusCode !== 200) + return false; + // skip health check related requests + var headToRoot = req.method === 'HEAD' && req.originalUrl === '/'; + var getToHealthcheck = req.method === 'GET' && req.originalUrl === '/healthcheck'; + return headToRoot || getToHealthcheck; + }, +})); +app.use(function (req, res, next) { + res.locals.cspNonce = crypto_1.default.randomBytes(16).toString('hex'); + next(); +}); +app.use((0, helmet_1.default)({ + crossOriginEmbedderPolicy: false, + contentSecurityPolicy: { + directives: { + 'connect-src': __spreadArray(__spreadArray([], (MODE === 'development' ? ['ws:'] : []), true), [ + "'self'", + ], false).filter(Boolean), + 'font-src': ["'self'"], + 'frame-src': [ + "'self'", + 'youtube.com', + 'www.youtube.com', + 'youtu.be', + 'youtube-nocookie.com', + 'www.youtube-nocookie.com', + 'player.simplecast.com', + 'egghead.io', + 'app.egghead.io', + 'calendar.google.com', + 'codesandbox.io', + 'share.transistor.fm', + 'codepen.io', + ], + 'img-src': __spreadArray([ + "'self'", + 'data:', + 'res.cloudinary.com', + 'www.gravatar.com', + 'cdn.usefathom.com', + 'pbs.twimg.com', + 'i.ytimg.com', + 'image.simplecastcdn.com', + 'images.transistor.fm', + 'img.transistor.fm', + 'i2.wp.com', + 'i1.wp.com', + 'og-image-react-egghead.now.sh', + 'og-image-react-egghead.vercel.app', + 'www.epicweb.dev' + ], (MODE === 'development' ? ['cloudflare-ipfs.com'] : []), true), + 'media-src': [ + "'self'", + 'res.cloudinary.com', + 'data:', + 'blob:', + 'www.dropbox.com', + '*.dropboxusercontent.com', + ], + 'script-src': [ + "'strict-dynamic'", + "'unsafe-eval'", + "'self'", + 'cdn.usefathom.com', + // @ts-expect-error middleware is the worst + function (req, res) { return "'nonce-".concat(res.locals.cspNonce, "'"); }, + ], + 'script-src-attr': [ + "'unsafe-inline'", + // TODO: figure out how to make the nonce work instead of + // unsafe-inline. I tried adding a nonce attribute where we're using + // inline attributes, but that didn't work. I still got that it + // violated the CSP. + ], + 'upgrade-insecure-requests': null, + }, + }, +})); +app.get('/redirect.html', redirects_js_1.rickRollMiddleware); +// CORS support for /.well-known/* +app.options('/.well-known/*', function (req, res) { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET,HEAD,POST,OPTIONS'); + res.header('Access-Control-Allow-Headers', req.header('Access-Control-Request-Headers') || '*'); + res.sendStatus(204); +}); +app.use('/.well-known/*', function (req, res, next) { + res.header('Access-Control-Allow-Origin', '*'); + next(); +}); +function getRequestHandler() { + return __awaiter(this, void 0, void 0, function () { + function getLoadContext(req, res) { + return { cspNonce: res.locals.cspNonce }; + } + var _a, _b; + var _c; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: + _a = express_1.createRequestHandler; + _c = {}; + if (!(MODE === 'development')) return [3 /*break*/, 1]; + _b = getBuild; + return [3 /*break*/, 3]; + case 1: return [4 /*yield*/, getBuild()]; + case 2: + _b = _d.sent(); + _d.label = 3; + case 3: return [2 /*return*/, _a.apply(void 0, [(_c.build = _b, + _c.mode = MODE, + _c.getLoadContext = getLoadContext, + _c)])]; + } + }); + }); +} +app.all('*', await getRequestHandler()); +var desiredPort = Number(process.env.PORT || 3000); +var portToUse = await (0, get_port_1.default)({ + port: (0, get_port_1.portNumbers)(desiredPort, desiredPort + 100), +}); +var server = app.listen(portToUse, function () { + var _a; + var addy = server.address(); + var portUsed = desiredPort === portToUse + ? desiredPort + : addy && typeof addy === 'object' + ? addy.port + : 0; + if (portUsed !== desiredPort) { + console.warn(chalk_1.default.yellow("\u26A0\uFE0F Port ".concat(desiredPort, " is not available, using ").concat(portUsed, " instead."))); + } + console.log("\n\uD83D\uDC28 let's get rolling!"); + var localUrl = "http://localhost:".concat(portUsed); + var lanUrl = null; + var localIp = (_a = (0, address_1.ip)()) !== null && _a !== void 0 ? _a : 'Unknown'; + // Check if the address is a private ip + // https://en.wikipedia.org/wiki/Private_network#Private_IPv4_address_spaces + // https://github.com/facebook/create-react-app/blob/d960b9e38c062584ff6cfb1a70e1512509a966e7/packages/react-dev-utils/WebpackDevServerUtils.js#LL48C9-L54C10 + if (/^10[.]|^172[.](1[6-9]|2[0-9]|3[0-1])[.]|^192[.]168[.]/.test(localIp)) { + lanUrl = "http://".concat(localIp, ":").concat(portUsed); + } + console.log("\n".concat(chalk_1.default.bold('Local:'), " ").concat(chalk_1.default.cyan(localUrl), "\n").concat(lanUrl ? "".concat(chalk_1.default.bold('On Your Network:'), " ").concat(chalk_1.default.cyan(lanUrl)) : '', "\n").concat(chalk_1.default.bold('Press Ctrl+C to stop'), "\n\t\t").trim()); +}); +var wss; +if (process.env.NODE_ENV === 'development') { + try { + var contentWatcher = (await Promise.resolve().then(function () { return require('./content-watcher.js'); })).contentWatcher; + wss = contentWatcher(server); + } + catch (error) { + console.error('unable to start content watcher', error); + } +} +(0, close_with_grace_1.default)(function () { + return Promise.all([ + new Promise(function (resolve, reject) { + server.close(function (e) { return (e ? reject(e) : resolve('ok')); }); + }), + new Promise(function (resolve, reject) { + wss === null || wss === void 0 ? void 0 : wss.close(function (e) { return (e ? reject(e) : resolve('ok')); }); + }), + ]); +}); +/* +eslint + @typescript-eslint/ban-ts-comment: "off", + @typescript-eslint/prefer-ts-expect-error: "off", + @typescript-eslint/no-shadow: "off", + import/namespace: "off", + no-inner-declarations: "off", +*/