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
-