Skip to content

Instantly share code, notes, and snippets.

@kentcdodds
Created August 4, 2023 06:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kentcdodds/757bc4901babf6de778f90b48c7c98d8 to your computer and use it in GitHub Desktop.
Save kentcdodds/757bc4901babf6de778f90b48c7c98d8 to your computer and use it in GitHub Desktop.
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/.env var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/.env
index d049969..4ac848d 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/.env
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/.env
@@ -1,2 +1,6 @@
DATABASE_URL="file:./data.db"
SESSION_SECRET="super-duper-s3cret"
+RESEND_API_KEY="some-secret-key"
+GITHUB_TOKEN="MOCK_abc12392lfkjlsf0"
+GITHUB_CLIENT_ID="MOCK_Iv1.abc12392lfkjlsf0"
+GITHUB_CLIENT_SECRET="MOCK_super-duper-s3cret-thing"
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/components/ui/icons/name.d.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/components/ui/icons/name.d.ts
index aebbd57..afa60b4 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/components/ui/icons/name.d.ts
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/components/ui/icons/name.d.ts
@@ -1,31 +1,31 @@
// This file is generated by npm run build:icons
export type IconName =
- | 'arrow-left'
- | 'arrow-right'
- | 'avatar'
- | 'backpack'
- | 'camera'
- | 'check'
- | 'clock'
- | 'cross-1'
- | 'dots-horizontal'
- | 'download'
- | 'envelope-closed'
- | 'exit'
- | 'file-text'
- | 'github-logo'
- | 'laptop'
- | 'link-2'
- | 'lock-closed'
- | 'lock-open-1'
- | 'magnifying-glass'
- | 'moon'
- | 'pencil-1'
- | 'pencil-2'
- | 'plus'
- | 'question-mark-circled'
- | 'reset'
- | 'sun'
- | 'trash'
- | 'update'
+ | "arrow-left"
+ | "arrow-right"
+ | "avatar"
+ | "backpack"
+ | "camera"
+ | "check"
+ | "clock"
+ | "cross-1"
+ | "dots-horizontal"
+ | "download"
+ | "envelope-closed"
+ | "exit"
+ | "file-text"
+ | "github-logo"
+ | "laptop"
+ | "link-2"
+ | "lock-closed"
+ | "lock-open-1"
+ | "magnifying-glass"
+ | "moon"
+ | "pencil-1"
+ | "pencil-2"
+ | "plus"
+ | "question-mark-circled"
+ | "reset"
+ | "sun"
+ | "trash"
+ | "update";
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/components/ui/icons/sprite.svg var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/components/ui/icons/sprite.svg
index d5da04e..d5770bf 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/components/ui/icons/sprite.svg
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/components/ui/icons/sprite.svg
@@ -93,6 +93,13 @@
fill="currentColor"
></path>
</symbol>
+<symbol viewBox="0 0 15 15" fill="none" id="github-logo">
+ <path fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M7.49933 0.25C3.49635 0.25 0.25 3.49593 0.25 7.50024C0.25 10.703 2.32715 13.4206 5.2081 14.3797C5.57084 14.446 5.70302 14.2222 5.70302 14.0299C5.70302 13.8576 5.69679 13.4019 5.69323 12.797C3.67661 13.235 3.25112 11.825 3.25112 11.825C2.92132 10.9874 2.44599 10.7644 2.44599 10.7644C1.78773 10.3149 2.49584 10.3238 2.49584 10.3238C3.22353 10.375 3.60629 11.0711 3.60629 11.0711C4.25298 12.1788 5.30335 11.8588 5.71638 11.6732C5.78225 11.205 5.96962 10.8854 6.17658 10.7043C4.56675 10.5209 2.87415 9.89918 2.87415 7.12104C2.87415 6.32925 3.15677 5.68257 3.62053 5.17563C3.54576 4.99226 3.29697 4.25521 3.69174 3.25691C3.69174 3.25691 4.30015 3.06196 5.68522 3.99973C6.26337 3.83906 6.8838 3.75895 7.50022 3.75583C8.1162 3.75895 8.73619 3.83906 9.31523 3.99973C10.6994 3.06196 11.3069 3.25691 11.3069 3.25691C11.7026 4.25521 11.4538 4.99226 11.3795 5.17563C11.8441 5.68257 12.1245 6.32925 12.1245 7.12104C12.1245 9.9063 10.4292 10.5192 8.81452 10.6985C9.07444 10.9224 9.30633 11.3648 9.30633 12.0413C9.30633 13.0102 9.29742 13.7922 9.29742 14.0299C9.29742 14.2239 9.42828 14.4496 9.79591 14.3788C12.6746 13.4179 14.75 10.7025 14.75 7.50024C14.75 3.49593 11.5036 0.25 7.49933 0.25Z"
+ fill="currentColor"
+ ></path>
+</symbol>
<symbol viewBox="0 0 15 15" fill="none" id="laptop">
<path fill-rule="evenodd"
clip-rule="evenodd"
@@ -100,6 +107,13 @@
fill="currentColor"
></path>
</symbol>
+<symbol viewBox="0 0 15 15" fill="none" id="link-2">
+ <path fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M8.51194 3.00541C9.18829 2.54594 10.0435 2.53694 10.6788 2.95419C10.8231 3.04893 10.9771 3.1993 11.389 3.61119C11.8009 4.02307 11.9513 4.17714 12.046 4.32141C12.4633 4.95675 12.4543 5.81192 11.9948 6.48827C11.8899 6.64264 11.7276 6.80811 11.3006 7.23511L10.6819 7.85383C10.4867 8.04909 10.4867 8.36567 10.6819 8.56093C10.8772 8.7562 11.1938 8.7562 11.389 8.56093L12.0077 7.94221L12.0507 7.89929C12.4203 7.52976 12.6568 7.2933 12.822 7.0502C13.4972 6.05623 13.5321 4.76252 12.8819 3.77248C12.7233 3.53102 12.4922 3.30001 12.1408 2.94871L12.0961 2.90408L12.0515 2.85942C11.7002 2.508 11.4692 2.27689 11.2277 2.11832C10.2377 1.46813 8.94398 1.50299 7.95001 2.17822C7.70691 2.34336 7.47044 2.57991 7.1009 2.94955L7.058 2.99247L6.43928 3.61119C6.24401 3.80645 6.24401 4.12303 6.43928 4.31829C6.63454 4.51355 6.95112 4.51355 7.14638 4.31829L7.7651 3.69957C8.1921 3.27257 8.35757 3.11027 8.51194 3.00541ZM4.31796 7.14672C4.51322 6.95146 4.51322 6.63487 4.31796 6.43961C4.12269 6.24435 3.80611 6.24435 3.61085 6.43961L2.99213 7.05833L2.94922 7.10124C2.57957 7.47077 2.34303 7.70724 2.17788 7.95035C1.50265 8.94432 1.4678 10.238 2.11799 11.2281C2.27656 11.4695 2.50766 11.7005 2.8591 12.0518L2.90374 12.0965L2.94837 12.1411C3.29967 12.4925 3.53068 12.7237 3.77214 12.8822C4.76219 13.5324 6.05589 13.4976 7.04986 12.8223C7.29296 12.6572 7.52943 12.4206 7.89896 12.051L7.89897 12.051L7.94188 12.0081L8.5606 11.3894C8.75586 11.1941 8.75586 10.8775 8.5606 10.6823C8.36533 10.487 8.04875 10.487 7.85349 10.6823L7.23477 11.301C6.80777 11.728 6.6423 11.8903 6.48794 11.9951C5.81158 12.4546 4.95642 12.4636 4.32107 12.0464C4.17681 11.9516 4.02274 11.8012 3.61085 11.3894C3.19896 10.9775 3.0486 10.8234 2.95385 10.6791C2.53661 10.0438 2.54561 9.18863 3.00507 8.51227C3.10993 8.35791 3.27224 8.19244 3.69924 7.76544L4.31796 7.14672ZM9.62172 6.08558C9.81698 5.89032 9.81698 5.57373 9.62172 5.37847C9.42646 5.18321 9.10988 5.18321 8.91461 5.37847L5.37908 8.91401C5.18382 9.10927 5.18382 9.42585 5.37908 9.62111C5.57434 9.81637 5.89092 9.81637 6.08619 9.62111L9.62172 6.08558Z"
+ fill="currentColor"
+ ></path>
+</symbol>
<symbol viewBox="0 0 15 15" fill="none" id="lock-closed">
<path fill-rule="evenodd"
clip-rule="evenodd"
@@ -149,6 +163,13 @@
fill="currentColor"
></path>
</symbol>
+<symbol viewBox="0 0 15 15" fill="none" id="question-mark-circled">
+ <path fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M0.877075 7.49972C0.877075 3.84204 3.84222 0.876892 7.49991 0.876892C11.1576 0.876892 14.1227 3.84204 14.1227 7.49972C14.1227 11.1574 11.1576 14.1226 7.49991 14.1226C3.84222 14.1226 0.877075 11.1574 0.877075 7.49972ZM7.49991 1.82689C4.36689 1.82689 1.82708 4.36671 1.82708 7.49972C1.82708 10.6327 4.36689 13.1726 7.49991 13.1726C10.6329 13.1726 13.1727 10.6327 13.1727 7.49972C13.1727 4.36671 10.6329 1.82689 7.49991 1.82689ZM8.24993 10.5C8.24993 10.9142 7.91414 11.25 7.49993 11.25C7.08571 11.25 6.74993 10.9142 6.74993 10.5C6.74993 10.0858 7.08571 9.75 7.49993 9.75C7.91414 9.75 8.24993 10.0858 8.24993 10.5ZM6.05003 6.25C6.05003 5.57211 6.63511 4.925 7.50003 4.925C8.36496 4.925 8.95003 5.57211 8.95003 6.25C8.95003 6.74118 8.68002 6.99212 8.21447 7.27494C8.16251 7.30651 8.10258 7.34131 8.03847 7.37854L8.03841 7.37858C7.85521 7.48497 7.63788 7.61119 7.47449 7.73849C7.23214 7.92732 6.95003 8.23198 6.95003 8.7C6.95004 9.00376 7.19628 9.25 7.50004 9.25C7.8024 9.25 8.04778 9.00601 8.05002 8.70417L8.05056 8.7033C8.05924 8.6896 8.08493 8.65735 8.15058 8.6062C8.25207 8.52712 8.36508 8.46163 8.51567 8.37436L8.51571 8.37433C8.59422 8.32883 8.68296 8.27741 8.78559 8.21506C9.32004 7.89038 10.05 7.35382 10.05 6.25C10.05 4.92789 8.93511 3.825 7.50003 3.825C6.06496 3.825 4.95003 4.92789 4.95003 6.25C4.95003 6.55376 5.19628 6.8 5.50003 6.8C5.80379 6.8 6.05003 6.55376 6.05003 6.25Z"
+ fill="currentColor"
+ ></path>
+</symbol>
<symbol viewBox="0 0 15 15" fill="none" id="reset">
<path fill-rule="evenodd"
clip-rule="evenodd"
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/root.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/root.tsx
index b7996bc..7b52a71 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/root.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/root.tsx
@@ -21,6 +21,8 @@ import {
type V2_MetaFunction,
} from '@remix-run/react'
import os from 'node:os'
+import { useEffect } from 'react'
+import { Toaster, toast as showToast } from 'sonner'
import { z } from 'zod'
import faviconAssetUrl from './assets/favicon.svg'
import { GeneralErrorBoundary } from './components/error-boundary.tsx'
@@ -36,9 +38,10 @@ import { getUserId } from './utils/auth.server.ts'
import { prisma } from './utils/db.server.ts'
import { getEnv } from './utils/env.server.ts'
import { getUserImgSrc, invariantResponse } from './utils/misc.tsx'
+import { userHasRole } from './utils/permissions.ts'
import { getTheme, setTheme, type Theme } from './utils/theme.server.ts'
+import { type Toast, getToast } from './utils/toast.server.ts'
import { useOptionalUser } from './utils/user.ts'
-import { userHasRole } from './utils/permissions.ts'
export const links: LinksFunction = () => {
return [
@@ -52,6 +55,7 @@ export const links: LinksFunction = () => {
}
export async function loader({ request }: DataFunctionArgs) {
+ const { toast, headers: toastHeaders } = await getToast(request)
const userId = await getUserId(request)
const user = userId
? await prisma.user.findUniqueOrThrow({
@@ -72,12 +76,16 @@ export async function loader({ request }: DataFunctionArgs) {
where: { id: userId },
})
: null
- return json({
- username: os.userInfo().username,
- user,
- theme: getTheme(request),
- ENV: getEnv(),
- })
+ return json(
+ {
+ username: os.userInfo().username,
+ user,
+ theme: getTheme(request),
+ toast,
+ ENV: getEnv(),
+ },
+ { headers: toastHeaders },
+ )
}
const ThemeFormSchema = z.object({
@@ -132,6 +140,7 @@ function Document({
__html: `window.ENV = ${JSON.stringify(env)}`,
}}
/>
+ <Toaster closeButton position="top-center" />
<ScrollRestoration />
<Scripts />
<KCDShop />
@@ -211,6 +220,7 @@ export default function App() {
</div>
</div>
<Spacer size="3xs" />
+ {data.toast ? <ShowToast toast={data.toast} /> : null}
</Document>
)
}
@@ -261,6 +271,7 @@ function ThemeSwitch({ userPreference }: { userPreference?: Theme }) {
<button
name="intent"
value="update-theme"
+ type="submit"
className="flex h-8 w-8 cursor-pointer items-center justify-center"
>
{modeLabel[mode]}
@@ -271,6 +282,16 @@ function ThemeSwitch({ userPreference }: { userPreference?: Theme }) {
)
}
+function ShowToast({ toast }: { toast: Toast }) {
+ const { id, type, title, description } = toast
+ useEffect(() => {
+ setTimeout(() => {
+ showToast[type](title, { id, description })
+ }, 0)
+ }, [description, id, title, type])
+ return null
+}
+
export const meta: V2_MetaFunction = () => {
return [
{ title: 'Epic Notes' },
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/auth.github.callback.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/auth.github.callback.ts
new file mode 100644
index 0000000..dc29871
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/auth.github.callback.ts
@@ -0,0 +1,179 @@
+import { redirect, type DataFunctionArgs } from '@remix-run/node'
+import { GitHubStrategy } from 'remix-auth-github'
+import {
+ SESSION_EXPIRATION_TIME,
+ authenticator,
+ getUserId,
+} from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { combineHeaders } from '~/utils/misc.tsx'
+import {
+ destroyRedirectToHeader,
+ getRedirectCookieValue,
+} from '~/utils/redirect-cookie.server.ts'
+import { sessionStorage } from '~/utils/session.server.ts'
+import { createToastHeaders, redirectWithToast } from '~/utils/toast.server.ts'
+import { verifySessionStorage } from '~/utils/verification.server.ts'
+import { handleNewSession } from './login.tsx'
+import {
+ githubIdKey,
+ onboardingEmailSessionKey,
+ prefilledProfileKey,
+} from './onboarding_.github.tsx'
+
+const destroyRedirectTo = { 'set-cookie': destroyRedirectToHeader }
+
+async function makeSession(
+ {
+ request,
+ userId,
+ redirectTo,
+ }: { request: Request; userId: string; redirectTo?: string | null },
+ responseInit?: ResponseInit,
+) {
+ redirectTo ??= '/'
+ const session = await prisma.session.create({
+ select: { id: true, expirationDate: true, userId: true },
+ data: {
+ expirationDate: new Date(Date.now() + SESSION_EXPIRATION_TIME),
+ userId,
+ },
+ })
+ return handleNewSession(
+ { request, session, redirectTo, remember: true },
+ { headers: combineHeaders(responseInit?.headers, destroyRedirectTo) },
+ )
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const reqUrl = new URL(request.url)
+ const redirectTo = getRedirectCookieValue(request)
+ if (
+ process.env.GITHUB_CLIENT_ID.startsWith('MOCK_') &&
+ reqUrl.searchParams.get('state') === 'MOCK_STATE'
+ ) {
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const state = cookieSession.get('oauth2:state') ?? 'MOCK_STATE'
+ cookieSession.set('oauth2:state', state)
+ reqUrl.searchParams.set('state', state)
+ request.headers.set(
+ 'cookie',
+ await sessionStorage.commitSession(cookieSession),
+ )
+ request = new Request(reqUrl.toString(), request)
+ }
+
+ const authResult = await authenticator
+ .authenticate(GitHubStrategy.name, request, { throwOnError: true })
+ .then(
+ data => ({ success: true, data }) as const,
+ error => ({ success: false, error }) as const,
+ )
+
+ if (!authResult.success) {
+ console.error(authResult.error)
+ return redirectWithToast(
+ '/login',
+ {
+ title: 'Auth Failed',
+ description: 'There was an error authenticating with GitHub.',
+ type: 'error',
+ },
+ { headers: destroyRedirectTo },
+ )
+ }
+
+ const { data: profile } = authResult
+
+ const existingConnection = await prisma.gitHubConnection.findUnique({
+ select: { userId: true },
+ where: { providerId: profile.id },
+ })
+
+ const userId = await getUserId(request)
+
+ if (existingConnection && userId) {
+ if (existingConnection.userId === userId) {
+ return redirectWithToast(
+ '/settings/profile/connections',
+ {
+ title: 'Already Connected',
+ description: `Your "${profile.username}" GitHub account is already connected.`,
+ },
+ { headers: destroyRedirectTo },
+ )
+ } else {
+ return redirectWithToast(
+ '/settings/profile/connections',
+ {
+ title: 'Already Connected',
+ description: `The "${profile.username}" GitHub account is already connected to another account.`,
+ },
+ { headers: destroyRedirectTo },
+ )
+ }
+ }
+
+ // If we're already logged in, then link the GitHub account
+ if (userId) {
+ await prisma.gitHubConnection.create({
+ data: { providerId: profile.id, userId },
+ })
+ return redirectWithToast(
+ '/settings/profile/connections',
+ {
+ title: 'Connected',
+ description: `Your "${profile.username}" GitHub account has been connected.`,
+ },
+ { headers: destroyRedirectTo },
+ )
+ }
+
+ // Connection exists already? Make a new session
+ if (existingConnection) {
+ return makeSession({ request, userId: existingConnection.userId })
+ }
+
+ // if the github email matches a user in the db, then link the account and
+ // make a new session
+ const user = await prisma.user.findUnique({
+ select: { id: true },
+ where: { email: profile.email },
+ })
+ if (user) {
+ await prisma.gitHubConnection.create({
+ data: { providerId: profile.id, userId: user.id },
+ })
+ return makeSession(
+ { request, userId: user.id },
+ {
+ headers: await createToastHeaders({
+ title: 'Connected',
+ description: `Your "${profile.username}" GitHub account has been connected.`,
+ }),
+ },
+ )
+ }
+
+ // this is a new user, so let's get them onboarded
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ verifySession.set(onboardingEmailSessionKey, profile.email)
+ verifySession.set(prefilledProfileKey, profile)
+ verifySession.set(githubIdKey, profile.id)
+ const onboardingRedirect = [
+ '/onboarding/github',
+ redirectTo ? new URLSearchParams({ redirectTo }) : null,
+ ]
+ .filter(Boolean)
+ .join('?')
+ throw redirect(onboardingRedirect, {
+ headers: combineHeaders(
+ { 'set-cookie': await verifySessionStorage.commitSession(verifySession) },
+ destroyRedirectTo,
+ ),
+ })
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/auth.github.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/auth.github.ts
new file mode 100644
index 0000000..2282aba
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/auth.github.ts
@@ -0,0 +1,34 @@
+import { redirect, type DataFunctionArgs } from '@remix-run/node'
+import { GitHubStrategy } from 'remix-auth-github'
+import { authenticator } from '~/utils/auth.server.ts'
+import { getReferrerRoute } from '~/utils/misc.tsx'
+import { getRedirectCookieHeader } from '~/utils/redirect-cookie.server.ts'
+
+export async function loader() {
+ return redirect('/login')
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const formData = await request.formData()
+ const rawRedirectTo = formData.get('redirectTo')
+ const redirectTo =
+ typeof rawRedirectTo === 'string'
+ ? rawRedirectTo
+ : getReferrerRoute(request)
+
+ const redirectToCookie = getRedirectCookieHeader(redirectTo)
+
+ if (process.env.GITHUB_CLIENT_ID.startsWith('MOCK_')) {
+ return redirect(`/auth/github/callback?code=MOCK_CODE&state=MOCK_STATE`, {
+ headers: redirectToCookie ? { 'set-cookie': redirectToCookie } : {},
+ })
+ }
+ try {
+ return await authenticator.authenticate(GitHubStrategy.name, request)
+ } catch (error: unknown) {
+ if (error instanceof Response && redirectToCookie) {
+ error.headers.append('set-cookie', redirectToCookie)
+ }
+ throw error
+ }
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/_auth+/forgot-password.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/forgot-password.tsx
index 6f6384a..6099fd3 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/_auth+/forgot-password.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/forgot-password.tsx
@@ -1,14 +1,184 @@
-export default function ForgotPassword() {
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import * as E from '@react-email/components'
+import {
+ json,
+ redirect,
+ type DataFunctionArgs,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import { Link, useFetcher } from '@remix-run/react'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'
+import { ErrorList, Field } from '~/components/forms.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import { prisma } from '~/utils/db.server.ts'
+import { sendEmail } from '~/utils/email.server.ts'
+import { emailSchema, usernameSchema } from '~/utils/user-validation.ts'
+import { prepareVerification } from './verify.tsx'
+
+const ForgotPasswordSchema = z.object({
+ usernameOrEmail: z.union([emailSchema, usernameSchema]),
+})
+
+export async function action({ request }: DataFunctionArgs) {
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ schema: ForgotPasswordSchema.superRefine(async (data, ctx) => {
+ const user = await prisma.user.findFirst({
+ where: {
+ OR: [
+ { email: data.usernameOrEmail },
+ { username: data.usernameOrEmail },
+ ],
+ },
+ select: { id: true },
+ })
+ if (!user) {
+ ctx.addIssue({
+ path: ['usernameOrEmail'],
+ code: z.ZodIssueCode.custom,
+ message: 'No user exists with this username or email',
+ })
+ return
+ }
+ }),
+ async: true,
+ acceptMultipleErrors: () => true,
+ })
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+ const { usernameOrEmail } = submission.value
+
+ const user = await prisma.user.findFirstOrThrow({
+ where: { OR: [{ email: usernameOrEmail }, { username: usernameOrEmail }] },
+ select: { email: true, username: true },
+ })
+
+ const { verifyUrl, redirectTo, otp } = await prepareVerification({
+ period: 10 * 60,
+ request,
+ type: 'reset-password',
+ target: usernameOrEmail,
+ })
+
+ const response = await sendEmail({
+ to: user.email,
+ subject: `Epic Notes Password Reset`,
+ react: (
+ <ForgotPasswordEmail onboardingUrl={verifyUrl.toString()} otp={otp} />
+ ),
+ })
+
+ if (response.status === 'success') {
+ return redirect(redirectTo.toString())
+ } else {
+ submission.error[''] = response.error.message
+ return json({ status: 'error', submission } as const, { status: 500 })
+ }
+}
+
+function ForgotPasswordEmail({
+ onboardingUrl,
+ otp,
+}: {
+ onboardingUrl: string
+ otp: string
+}) {
+ return (
+ <E.Html lang="en" dir="ltr">
+ <E.Container>
+ <h1>
+ <E.Text>Epic Notes Password Reset</E.Text>
+ </h1>
+ <p>
+ <E.Text>
+ Here's your verification code: <strong>{otp}</strong>
+ </E.Text>
+ </p>
+ <p>
+ <E.Text>Or click the link:</E.Text>
+ </p>
+ <E.Link href={onboardingUrl}>{onboardingUrl}</E.Link>
+ </E.Container>
+ </E.Html>
+ )
+}
+
+export const meta: V2_MetaFunction = () => {
+ return [{ title: 'Password Recovery for Epic Notes' }]
+}
+
+export default function ForgotPasswordRoute() {
+ const forgotPassword = useFetcher<typeof action>()
+
+ const [form, fields] = useForm({
+ id: 'forgot-password-form',
+ constraint: getFieldsetConstraint(ForgotPasswordSchema),
+ lastSubmission: forgotPassword.data?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: ForgotPasswordSchema })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
return (
<div className="container pb-32 pt-20">
<div className="flex flex-col justify-center">
<div className="text-center">
<h1 className="text-h1">Forgot Password</h1>
<p className="mt-3 text-body-md text-muted-foreground">
- No worries, shoot support an email and we'll get you fixed up.
+ No worries, we'll send you reset instructions.
</p>
</div>
+ <div className="mx-auto mt-16 min-w-[368px] max-w-sm">
+ <forgotPassword.Form method="POST" {...form.props}>
+ <div>
+ <Field
+ labelProps={{
+ htmlFor: fields.usernameOrEmail.id,
+ children: 'Username or Email',
+ }}
+ inputProps={{
+ autoFocus: true,
+ ...conform.input(fields.usernameOrEmail),
+ }}
+ errors={fields.usernameOrEmail.errors}
+ />
+ </div>
+ <ErrorList errors={form.errors} id={form.errorId} />
+
+ <div className="mt-6">
+ <StatusButton
+ className="w-full"
+ status={
+ forgotPassword.state === 'submitting'
+ ? 'pending'
+ : forgotPassword.data?.status ?? 'idle'
+ }
+ type="submit"
+ disabled={forgotPassword.state !== 'idle'}
+ >
+ Recover password
+ </StatusButton>
+ </div>
+ </forgotPassword.Form>
+ <Link
+ to="/login"
+ className="mt-11 text-center text-body-sm font-bold"
+ >
+ Back to Login
+ </Link>
+ </div>
</div>
</div>
)
}
+
+export function ErrorBoundary() {
+ return <GeneralErrorBoundary />
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/_auth+/login.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/login.tsx
index 3442ba1..bb4e958 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/_auth+/login.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/login.tsx
@@ -13,11 +13,141 @@ import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'
import { CheckboxField, ErrorList, Field } from '~/components/forms.tsx'
import { Spacer } from '~/components/spacer.tsx'
import { StatusButton } from '~/components/ui/status-button.tsx'
-import { login, requireAnonymous, sessionKey } from '~/utils/auth.server.ts'
-import { useIsPending } from '~/utils/misc.tsx'
-import { commitSession, getSession } from '~/utils/session.server.ts'
+import {
+ getUserId,
+ login,
+ requireAnonymous,
+ sessionKey,
+} from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { combineResponseInits, invariant, useIsPending } from '~/utils/misc.tsx'
+import { sessionStorage } from '~/utils/session.server.ts'
import { passwordSchema, usernameSchema } from '~/utils/user-validation.ts'
+import { verifySessionStorage } from '~/utils/verification.server.ts'
import { checkboxSchema } from '~/utils/zod-extensions.ts'
+import { twoFAVerificationType } from '../settings+/profile.two-factor.tsx'
+import { getRedirectToUrl, type VerifyFunctionArgs } from './verify.tsx'
+
+const verifiedTimeKey = 'verified-time'
+const unverifiedSessionIdKey = 'unverified-session-id'
+
+export async function handleNewSession(
+ {
+ request,
+ session,
+ redirectTo,
+ remember,
+ }: {
+ request: Request
+ session: { userId: string; id: string; expirationDate: Date }
+ redirectTo?: string
+ remember: boolean
+ },
+ responseInit?: ResponseInit,
+) {
+ const verification = await prisma.verification.findUnique({
+ select: { id: true },
+ where: {
+ target_type: { target: session.userId, type: twoFAVerificationType },
+ },
+ })
+ const userHasTwoFactor = Boolean(verification)
+
+ if (userHasTwoFactor) {
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ verifySession.set(unverifiedSessionIdKey, session.id)
+ const redirectUrl = getRedirectToUrl({
+ request,
+ type: twoFAVerificationType,
+ target: session.userId,
+ })
+ return redirect(
+ redirectUrl.toString(),
+ combineResponseInits(
+ {
+ headers: {
+ 'set-cookie': await verifySessionStorage.commitSession(
+ verifySession,
+ ),
+ },
+ },
+ responseInit,
+ ),
+ )
+ } else {
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ cookieSession.set(sessionKey, session.id)
+
+ return redirect(
+ safeRedirect(redirectTo),
+ combineResponseInits(
+ {
+ headers: {
+ 'set-cookie': await sessionStorage.commitSession(cookieSession, {
+ expires: remember ? session.expirationDate : undefined,
+ }),
+ },
+ },
+ responseInit,
+ ),
+ )
+ }
+}
+
+export async function handleVerification({
+ request,
+ submission,
+}: VerifyFunctionArgs) {
+ invariant(submission.value, 'Submission should have a value by this point')
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ if (verifySession.has(unverifiedSessionIdKey)) {
+ cookieSession.set(sessionKey, verifySession.get(unverifiedSessionIdKey))
+ }
+ const { redirectTo } = submission.value
+ cookieSession.set(verifiedTimeKey, Date.now())
+
+ const headers = new Headers()
+ headers.append(
+ 'set-cookie',
+ await sessionStorage.commitSession(cookieSession),
+ )
+ headers.append(
+ 'set-cookie',
+ await verifySessionStorage.destroySession(verifySession),
+ )
+
+ return redirect(safeRedirect(redirectTo), { headers })
+}
+
+export async function shouldRequestTwoFA(request: Request) {
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ if (verifySession.has(unverifiedSessionIdKey)) return true
+ const userId = await getUserId(request)
+ if (!userId) return false
+ // if it's over two hours since they last verified, we should request 2FA again
+ const userHasTwoFA = await prisma.verification.findUnique({
+ select: { id: true },
+ where: { target_type: { target: userId, type: twoFAVerificationType } },
+ })
+ if (!userHasTwoFA) return false
+ const verifiedTime = cookieSession.get(verifiedTimeKey) ?? new Date(0)
+ const twoHours = 1000 * 60 * 60 * 2
+ return Date.now() - verifiedTime > twoHours
+}
const LoginFormSchema = z.object({
username: usernameSchema,
@@ -66,22 +196,13 @@ export async function action({ request }: DataFunctionArgs) {
const { session, remember, redirectTo } = submission.value
- const cookieSession = await getSession(request.headers.get('cookie'))
- cookieSession.set(sessionKey, session.id)
-
- return redirect(safeRedirect(redirectTo), {
- headers: {
- 'set-cookie': await commitSession(cookieSession, {
- // Cookies with no expiration are cleared when the tab/window closes
- expires: remember ? session.expirationDate : undefined,
- }),
- },
- })
+ return handleNewSession({ request, session, remember, redirectTo })
}
export default function LoginPage() {
const actionData = useActionData<typeof action>()
const isPending = useIsPending()
+ const isGitHubSubmitting = useIsPending({ formAction: '/auth/github' })
const [searchParams] = useSearchParams()
const redirectTo = searchParams.get('redirectTo')
@@ -150,7 +271,9 @@ export default function LoginPage() {
</div>
</div>
- <input {...conform.input(fields.redirectTo)} type="hidden" />
+ <input
+ {...conform.input(fields.redirectTo, { type: 'hidden' })}
+ />
<ErrorList errors={form.errors} id={form.errorId} />
<div className="flex items-center justify-between gap-6 pt-3">
@@ -164,6 +287,24 @@ export default function LoginPage() {
</StatusButton>
</div>
</Form>
+ <Form
+ className="mt-5 flex items-center justify-center gap-2 border-t-2 border-border pt-3"
+ action="/auth/github"
+ method="POST"
+ >
+ <input
+ type="hidden"
+ name="redirectTo"
+ value={redirectTo ?? '/'}
+ />
+ <StatusButton
+ type="submit"
+ className="w-full"
+ status={isGitHubSubmitting ? 'pending' : 'idle'}
+ >
+ Login with GitHub
+ </StatusButton>
+ </Form>
<div className="flex items-center justify-center gap-2 pt-6">
<span className="text-muted-foreground">New here?</span>
<Link
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/_auth+/logout.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/logout.tsx
index c8641b0..8fdc6da 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/_auth+/logout.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/logout.tsx
@@ -6,5 +6,5 @@ export async function loader() {
}
export async function action({ request }: DataFunctionArgs) {
- return logout(request)
+ return logout({ request })
}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/onboarding.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/onboarding.tsx
new file mode 100644
index 0000000..456eec5
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/onboarding.tsx
@@ -0,0 +1,257 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import {
+ json,
+ redirect,
+ type DataFunctionArgs,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import {
+ Form,
+ useActionData,
+ useLoaderData,
+ useSearchParams,
+} from '@remix-run/react'
+import { safeRedirect } from 'remix-utils'
+import { z } from 'zod'
+import { CheckboxField, ErrorList, Field } from '~/components/forms.tsx'
+import { Spacer } from '~/components/spacer.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import { requireAnonymous, sessionKey, signup } from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { invariant, useIsPending } from '~/utils/misc.tsx'
+import { sessionStorage } from '~/utils/session.server.ts'
+import {
+ nameSchema,
+ passwordSchema,
+ usernameSchema,
+} from '~/utils/user-validation.ts'
+import { verifySessionStorage } from '~/utils/verification.server.ts'
+import { checkboxSchema } from '~/utils/zod-extensions.ts'
+import { type VerifyFunctionArgs } from './verify.tsx'
+
+const onboardingEmailSessionKey = 'onboardingEmail'
+
+const SignupFormSchema = z
+ .object({
+ username: usernameSchema,
+ name: nameSchema,
+ password: passwordSchema,
+ confirmPassword: passwordSchema,
+ agreeToTermsOfServiceAndPrivacyPolicy: checkboxSchema(
+ 'You must agree to the terms of service and privacy policy',
+ ),
+ remember: checkboxSchema(),
+ redirectTo: z.string().optional(),
+ })
+ .superRefine(({ confirmPassword, password }, ctx) => {
+ if (confirmPassword !== password) {
+ ctx.addIssue({
+ path: ['confirmPassword'],
+ code: 'custom',
+ message: 'The passwords must match',
+ })
+ }
+ })
+
+async function requireOnboardingEmail(request: Request) {
+ await requireAnonymous(request)
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const email = verifySession.get(onboardingEmailSessionKey)
+ if (typeof email !== 'string' || !email) {
+ throw redirect('/signup')
+ }
+ return email
+}
+export async function loader({ request }: DataFunctionArgs) {
+ const email = await requireOnboardingEmail(request)
+ return json({ email })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const email = await requireOnboardingEmail(request)
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ schema: SignupFormSchema.superRefine(async (data, ctx) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { username: data.username },
+ select: { id: true },
+ })
+ if (existingUser) {
+ ctx.addIssue({
+ path: ['username'],
+ code: z.ZodIssueCode.custom,
+ message: 'A user already exists with this username',
+ })
+ return
+ }
+ }).transform(async data => {
+ const session = await signup({ ...data, email })
+ return { ...data, session }
+ }),
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value?.session) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { session, remember, redirectTo } = submission.value
+
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ cookieSession.set(sessionKey, session.id)
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const headers = new Headers()
+ headers.append(
+ 'set-cookie',
+ await sessionStorage.commitSession(cookieSession, {
+ expires: remember ? session.expirationDate : undefined,
+ }),
+ )
+ headers.append(
+ 'set-cookie',
+ await verifySessionStorage.destroySession(verifySession),
+ )
+
+ return redirect(safeRedirect(redirectTo), { headers })
+}
+
+export async function handleVerification({
+ request,
+ submission,
+}: VerifyFunctionArgs) {
+ invariant(submission.value, 'submission.value should be defined by now')
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ verifySession.set(onboardingEmailSessionKey, submission.value.target)
+ return redirect('/onboarding', {
+ headers: {
+ 'set-cookie': await verifySessionStorage.commitSession(verifySession),
+ },
+ })
+}
+
+export const meta: V2_MetaFunction = () => {
+ return [{ title: 'Setup Epic Notes Account' }]
+}
+
+export default function SignupRoute() {
+ const data = useLoaderData<typeof loader>()
+ const actionData = useActionData<typeof action>()
+ const isPending = useIsPending()
+ const [searchParams] = useSearchParams()
+ const redirectTo = searchParams.get('redirectTo')
+
+ const [form, fields] = useForm({
+ id: 'signup-form',
+ constraint: getFieldsetConstraint(SignupFormSchema),
+ defaultValue: { redirectTo },
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: SignupFormSchema })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+ <div className="container flex min-h-full flex-col justify-center pb-32 pt-20">
+ <div className="mx-auto w-full max-w-lg">
+ <div className="flex flex-col gap-3 text-center">
+ <h1 className="text-h1">Welcome aboard {data.email}!</h1>
+ <p className="text-body-md text-muted-foreground">
+ Please enter your details.
+ </p>
+ </div>
+ <Spacer size="xs" />
+ <Form
+ method="POST"
+ className="mx-auto min-w-[368px] max-w-sm"
+ {...form.props}
+ >
+ <Field
+ labelProps={{ htmlFor: fields.username.id, children: 'Username' }}
+ inputProps={{
+ ...conform.input(fields.username),
+ autoComplete: 'username',
+ className: 'lowercase',
+ }}
+ errors={fields.username.errors}
+ />
+ <Field
+ labelProps={{ htmlFor: fields.name.id, children: 'Name' }}
+ inputProps={{
+ ...conform.input(fields.name),
+ autoComplete: 'name',
+ }}
+ errors={fields.name.errors}
+ />
+ <Field
+ labelProps={{ htmlFor: fields.password.id, children: 'Password' }}
+ inputProps={{
+ ...conform.input(fields.password, { type: 'password' }),
+ autoComplete: 'new-password',
+ }}
+ errors={fields.password.errors}
+ />
+
+ <Field
+ labelProps={{
+ htmlFor: fields.confirmPassword.id,
+ children: 'Confirm Password',
+ }}
+ inputProps={{
+ ...conform.input(fields.confirmPassword, { type: 'password' }),
+ autoComplete: 'new-password',
+ }}
+ errors={fields.confirmPassword.errors}
+ />
+
+ <CheckboxField
+ labelProps={{
+ htmlFor: fields.agreeToTermsOfServiceAndPrivacyPolicy.id,
+ children:
+ 'Do you agree to our Terms of Service and Privacy Policy?',
+ }}
+ buttonProps={conform.input(
+ fields.agreeToTermsOfServiceAndPrivacyPolicy,
+ { type: 'checkbox' },
+ )}
+ errors={fields.agreeToTermsOfServiceAndPrivacyPolicy.errors}
+ />
+ <CheckboxField
+ labelProps={{
+ htmlFor: fields.remember.id,
+ children: 'Remember me',
+ }}
+ buttonProps={conform.input(fields.remember, { type: 'checkbox' })}
+ errors={fields.remember.errors}
+ />
+
+ <input {...conform.input(fields.redirectTo, { type: 'hidden' })} />
+ <ErrorList errors={form.errors} id={form.errorId} />
+
+ <div className="flex items-center justify-between gap-6">
+ <StatusButton
+ className="w-full"
+ status={isPending ? 'pending' : actionData?.status ?? 'idle'}
+ type="submit"
+ disabled={isPending}
+ >
+ Create an account
+ </StatusButton>
+ </div>
+ </Form>
+ </div>
+ </div>
+ )
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/onboarding_.github.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/onboarding_.github.tsx
new file mode 100644
index 0000000..aadc271
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/onboarding_.github.tsx
@@ -0,0 +1,275 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import {
+ json,
+ redirect,
+ type DataFunctionArgs,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import {
+ Form,
+ useActionData,
+ useLoaderData,
+ useSearchParams,
+} from '@remix-run/react'
+import { safeRedirect } from 'remix-utils'
+import { z } from 'zod'
+import { CheckboxField, ErrorList, Field } from '~/components/forms.tsx'
+import { Spacer } from '~/components/spacer.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import {
+ authenticator,
+ requireAnonymous,
+ sessionKey,
+ signupWithGitHub,
+} from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { invariant, useIsPending } from '~/utils/misc.tsx'
+import { sessionStorage } from '~/utils/session.server.ts'
+import { nameSchema, usernameSchema } from '~/utils/user-validation.ts'
+import { verifySessionStorage } from '~/utils/verification.server.ts'
+import { checkboxSchema } from '~/utils/zod-extensions.ts'
+import { type VerifyFunctionArgs } from './verify.tsx'
+
+export const onboardingEmailSessionKey = 'onboardingEmail'
+export const githubIdKey = 'ghProfileId'
+export const prefilledProfileKey = 'prefilledProfile'
+
+const SignupFormSchema = z.object({
+ imageUrl: z.string().optional(),
+ username: usernameSchema,
+ name: nameSchema,
+ agreeToTermsOfServiceAndPrivacyPolicy: checkboxSchema(
+ 'You must agree to the terms of service and privacy policy',
+ ),
+ remember: checkboxSchema(),
+ redirectTo: z.string().optional(),
+})
+
+async function requireOnboardingEmailAndGitHubId(request: Request) {
+ await requireAnonymous(request)
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const email = verifySession.get(onboardingEmailSessionKey)
+ const gitHubId = verifySession.get(githubIdKey)
+ const result = z
+ .object({ email: z.string(), gitHubId: z.string() })
+ .safeParse({ email, gitHubId: gitHubId })
+ if (result.success) {
+ return result.data
+ }
+ throw redirect('/signup')
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const { email } = await requireOnboardingEmailAndGitHubId(request)
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const prefilledProfile = verifySession.get(prefilledProfileKey)
+
+ const formError = cookieSession.get(authenticator.sessionErrorKey)
+
+ return json({
+ email,
+ formError: typeof formError === 'string' ? formError : null,
+ status: 'idle',
+ submission: {
+ intent: '',
+ payload: (prefilledProfile ?? {}) as {},
+ error: {
+ '': typeof formError === 'string' ? [formError] : [],
+ },
+ },
+ })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const { email, gitHubId } = await requireOnboardingEmailAndGitHubId(request)
+ const formData = await request.formData()
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+
+ const submission = await parse(formData, {
+ schema: SignupFormSchema.superRefine(async (data, ctx) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { username: data.username },
+ select: { id: true },
+ })
+ if (existingUser) {
+ ctx.addIssue({
+ path: ['username'],
+ code: z.ZodIssueCode.custom,
+ message: 'A user already exists with this username',
+ })
+ return
+ }
+ }).transform(async data => {
+ const session = await signupWithGitHub({
+ ...data,
+ email,
+ gitHubId: gitHubId,
+ })
+ return { ...data, session }
+ }),
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value?.session) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { session, remember, redirectTo } = submission.value
+
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ cookieSession.set(sessionKey, session.id)
+ const headers = new Headers()
+ headers.append(
+ 'set-cookie',
+ await sessionStorage.commitSession(cookieSession, {
+ expires: remember ? session.expirationDate : undefined,
+ }),
+ )
+ headers.append(
+ 'set-cookie',
+ await verifySessionStorage.destroySession(verifySession),
+ )
+
+ return redirect(safeRedirect(redirectTo), { headers })
+}
+
+export async function handleVerification({
+ request,
+ submission,
+}: VerifyFunctionArgs) {
+ invariant(submission.value, 'submission.value should be defined by now')
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ verifySession.set(onboardingEmailSessionKey, submission.value.target)
+ return redirect('/onboarding', {
+ headers: {
+ 'set-cookie': await verifySessionStorage.commitSession(verifySession),
+ },
+ })
+}
+
+export const meta: V2_MetaFunction = () => {
+ return [{ title: 'Setup Epic Notes Account' }]
+}
+
+export default function SignupRoute() {
+ const data = useLoaderData<typeof loader>()
+ const actionData = useActionData<typeof action>()
+ const isPending = useIsPending()
+ const [searchParams] = useSearchParams()
+ const redirectTo = searchParams.get('redirectTo')
+
+ const [form, fields] = useForm({
+ id: 'signup-form',
+ constraint: getFieldsetConstraint(SignupFormSchema),
+ lastSubmission: actionData?.submission ?? data.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: SignupFormSchema })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+ <div className="container flex min-h-full flex-col justify-center pb-32 pt-20">
+ <div className="mx-auto w-full max-w-lg">
+ <div className="flex flex-col gap-3 text-center">
+ <h1 className="text-h1">Welcome aboard {data.email}!</h1>
+ <p className="text-body-md text-muted-foreground">
+ Please enter your details.
+ </p>
+ </div>
+ <Spacer size="xs" />
+ <Form
+ method="POST"
+ className="mx-auto min-w-[368px] max-w-sm"
+ {...form.props}
+ >
+ {fields.imageUrl.defaultValue ? (
+ <div className="flex justify-center gap-4 items-center flex-col mb-4">
+ <img
+ src={fields.imageUrl.defaultValue}
+ alt="Profile"
+ className="rounded-full w-24 h-24"
+ />
+ <p className="text-body-sm text-muted-foreground">
+ You can change your photo later
+ </p>
+ <input {...conform.input(fields.imageUrl, { type: 'hidden' })} />
+ </div>
+ ) : null}
+ <Field
+ labelProps={{ htmlFor: fields.username.id, children: 'Username' }}
+ inputProps={{
+ ...conform.input(fields.username),
+ autoComplete: 'username',
+ className: 'lowercase',
+ }}
+ errors={fields.username.errors}
+ />
+ <Field
+ labelProps={{ htmlFor: fields.name.id, children: 'Name' }}
+ inputProps={{
+ ...conform.input(fields.name),
+ autoComplete: 'name',
+ }}
+ errors={fields.name.errors}
+ />
+
+ <CheckboxField
+ labelProps={{
+ htmlFor: fields.agreeToTermsOfServiceAndPrivacyPolicy.id,
+ children:
+ 'Do you agree to our Terms of Service and Privacy Policy?',
+ }}
+ buttonProps={conform.input(
+ fields.agreeToTermsOfServiceAndPrivacyPolicy,
+ { type: 'checkbox' },
+ )}
+ errors={fields.agreeToTermsOfServiceAndPrivacyPolicy.errors}
+ />
+ <CheckboxField
+ labelProps={{
+ htmlFor: fields.remember.id,
+ children: 'Remember me',
+ }}
+ buttonProps={conform.input(fields.remember, { type: 'checkbox' })}
+ errors={fields.remember.errors}
+ />
+
+ {redirectTo ? (
+ <input type="hidden" name="redirectTo" value={redirectTo} />
+ ) : null}
+
+ <ErrorList errors={form.errors} id={form.errorId} />
+
+ <div className="flex items-center justify-between gap-6">
+ <StatusButton
+ className="w-full"
+ status={isPending ? 'pending' : actionData?.status ?? 'idle'}
+ type="submit"
+ disabled={isPending}
+ >
+ Create an account
+ </StatusButton>
+ </div>
+ </Form>
+ </div>
+ </div>
+ )
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/reset-password.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/reset-password.tsx
new file mode 100644
index 0000000..0bc8257
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/reset-password.tsx
@@ -0,0 +1,184 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import {
+ json,
+ redirect,
+ type DataFunctionArgs,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import { Form, useActionData, useLoaderData } from '@remix-run/react'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'
+import { ErrorList, Field } from '~/components/forms.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import {
+ logout,
+ requireAnonymous,
+ resetUserPassword,
+} from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { invariant, useIsPending } from '~/utils/misc.tsx'
+import { passwordSchema } from '~/utils/user-validation.ts'
+import { verifySessionStorage } from '~/utils/verification.server.ts'
+import { type VerifyFunctionArgs } from './verify.tsx'
+
+const resetPasswordUsernameSessionKey = 'resetPasswordUsername'
+
+export async function handleVerification({
+ request,
+ submission,
+}: VerifyFunctionArgs) {
+ invariant(submission.value, 'submission.value should be defined by now')
+ const target = submission.value.target
+ const user = await prisma.user.findFirst({
+ where: { OR: [{ email: target }, { username: target }] },
+ select: { email: true, username: true },
+ })
+ // we don't want to say the user is not found if the email is not found
+ // because that would allow an attacker to check if an email is registered
+ if (!user) {
+ submission.error.code = 'Invalid code'
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ verifySession.set(resetPasswordUsernameSessionKey, user.username)
+ return redirect('/reset-password', {
+ headers: {
+ 'set-cookie': await verifySessionStorage.commitSession(verifySession),
+ },
+ })
+}
+
+const ResetPasswordSchema = z
+ .object({
+ password: passwordSchema,
+ confirmPassword: passwordSchema,
+ })
+ .refine(({ confirmPassword, password }) => password === confirmPassword, {
+ message: 'The passwords did not match',
+ path: ['confirmPassword'],
+ })
+
+async function requireResetPasswordUsername(request: Request) {
+ await requireAnonymous(request)
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const resetPasswordUsername = verifySession.get(
+ resetPasswordUsernameSessionKey,
+ )
+ if (typeof resetPasswordUsername !== 'string' || !resetPasswordUsername) {
+ throw redirect('/login')
+ }
+ return resetPasswordUsername
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const resetPasswordUsername = await requireResetPasswordUsername(request)
+ return json({ resetPasswordUsername })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const resetPasswordUsername = await requireResetPasswordUsername(request)
+ const formData = await request.formData()
+ const submission = parse(formData, {
+ schema: ResetPasswordSchema,
+ acceptMultipleErrors: () => true,
+ })
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value?.password) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+ const { password } = submission.value
+
+ await resetUserPassword({ username: resetPasswordUsername, password })
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ throw await logout(
+ { request, redirectTo: '/login' },
+ {
+ headers: {
+ 'set-cookie': await verifySessionStorage.destroySession(verifySession),
+ },
+ },
+ )
+}
+
+export const meta: V2_MetaFunction = () => {
+ return [{ title: 'Reset Password | Epic Notes' }]
+}
+
+export default function ResetPasswordPage() {
+ const data = useLoaderData<typeof loader>()
+ const actionData = useActionData<typeof action>()
+ const isPending = useIsPending()
+
+ const [form, fields] = useForm({
+ id: 'reset-password',
+ constraint: getFieldsetConstraint(ResetPasswordSchema),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: ResetPasswordSchema })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+ <div className="container flex flex-col justify-center pb-32 pt-20">
+ <div className="text-center">
+ <h1 className="text-h1">Password Reset</h1>
+ <p className="mt-3 text-body-md text-muted-foreground">
+ Hi, {data.resetPasswordUsername}. No worries. It happens all the time.
+ </p>
+ </div>
+ <div className="mx-auto mt-16 min-w-[368px] max-w-sm">
+ <Form method="POST" {...form.props}>
+ <Field
+ labelProps={{
+ htmlFor: fields.password.id,
+ children: 'New Password',
+ }}
+ inputProps={{
+ ...conform.input(fields.password, { type: 'password' }),
+ autoComplete: 'new-password',
+ autoFocus: true,
+ }}
+ errors={fields.password.errors}
+ />
+ <Field
+ labelProps={{
+ htmlFor: fields.confirmPassword.id,
+ children: 'Confirm Password',
+ }}
+ inputProps={{
+ ...conform.input(fields.confirmPassword, { type: 'password' }),
+ autoComplete: 'new-password',
+ }}
+ errors={fields.confirmPassword.errors}
+ />
+
+ <ErrorList errors={form.errors} id={form.errorId} />
+
+ <StatusButton
+ className="w-full"
+ status={isPending ? 'pending' : actionData?.status ?? 'idle'}
+ type="submit"
+ disabled={isPending}
+ >
+ Reset password
+ </StatusButton>
+ </Form>
+ </div>
+ </div>
+ )
+}
+
+export function ErrorBoundary() {
+ return <GeneralErrorBoundary />
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/_auth+/signup.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/signup.tsx
index d3292a4..bbf70c5 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/_auth+/signup.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/signup.tsx
@@ -1,5 +1,6 @@
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import * as E from '@react-email/components'
import {
json,
redirect,
@@ -7,217 +8,164 @@ import {
type V2_MetaFunction,
} from '@remix-run/node'
import { Form, useActionData, useSearchParams } from '@remix-run/react'
-import { safeRedirect } from 'remix-utils'
import { z } from 'zod'
-import { CheckboxField, ErrorList, Field } from '~/components/forms.tsx'
-import { Spacer } from '~/components/spacer.tsx'
+import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'
+import { ErrorList, Field } from '~/components/forms.tsx'
import { StatusButton } from '~/components/ui/status-button.tsx'
-import { requireAnonymous, sessionKey, signup } from '~/utils/auth.server.ts'
import { prisma } from '~/utils/db.server.ts'
+import { sendEmail } from '~/utils/email.server.ts'
import { useIsPending } from '~/utils/misc.tsx'
-import { commitSession, getSession } from '~/utils/session.server.ts'
-import {
- emailSchema,
- nameSchema,
- passwordSchema,
- usernameSchema,
-} from '~/utils/user-validation.ts'
-import { checkboxSchema } from '~/utils/zod-extensions.ts'
+import { emailSchema } from '~/utils/user-validation.ts'
+import { prepareVerification } from './verify.tsx'
-const SignupFormSchema = z
- .object({
- username: usernameSchema,
- name: nameSchema,
- email: emailSchema,
- password: passwordSchema,
- confirmPassword: passwordSchema,
- agreeToTermsOfServiceAndPrivacyPolicy: checkboxSchema(
- 'You must agree to the terms of service and privacy policy',
- ),
- remember: checkboxSchema(),
- redirectTo: z.string().optional(),
- })
- .superRefine(({ confirmPassword, password }, ctx) => {
- if (confirmPassword !== password) {
- ctx.addIssue({
- path: ['confirmPassword'],
- code: 'custom',
- message: 'The passwords must match',
- })
- }
- })
-
-export async function loader({ request }: DataFunctionArgs) {
- await requireAnonymous(request)
- return json({})
-}
+const SignupSchema = z.object({
+ email: emailSchema,
+})
export async function action({ request }: DataFunctionArgs) {
- await requireAnonymous(request)
const formData = await request.formData()
const submission = await parse(formData, {
- schema: SignupFormSchema.superRefine(async (data, ctx) => {
+ schema: SignupSchema.superRefine(async (data, ctx) => {
const existingUser = await prisma.user.findUnique({
- where: { username: data.username },
+ where: { email: data.email },
select: { id: true },
})
if (existingUser) {
ctx.addIssue({
- path: ['username'],
+ path: ['email'],
code: z.ZodIssueCode.custom,
- message: 'A user already exists with this username',
+ message: 'A user already exists with this email',
})
return
}
- }).transform(async data => {
- const session = await signup(data)
- return { ...data, session }
}),
+ acceptMultipleErrors: () => true,
async: true,
})
-
if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}
- if (!submission.value?.session) {
+ if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}
+ const { email } = submission.value
+ const { verifyUrl, redirectTo, otp } = await prepareVerification({
+ period: 10 * 60,
+ request,
+ type: 'onboarding',
+ target: email,
+ })
- const { session, remember, redirectTo } = submission.value
+ const response = await sendEmail({
+ to: email,
+ subject: `Welcome to Epic Notes!`,
+ react: <SignupEmail onboardingUrl={verifyUrl.toString()} otp={otp} />,
+ })
- const cookieSession = await getSession(request.headers.get('cookie'))
- cookieSession.set(sessionKey, session.id)
+ if (response.status === 'success') {
+ return redirect(redirectTo.toString())
+ } else {
+ submission.error[''] = response.error.message
+ return json({ status: 'error', submission } as const, { status: 500 })
+ }
+}
- return redirect(safeRedirect(redirectTo), {
- headers: {
- 'set-cookie': await commitSession(cookieSession, {
- // Cookies with no expiration are cleared when the tab/window closes
- expires: remember ? session.expirationDate : undefined,
- }),
- },
- })
+export function SignupEmail({
+ onboardingUrl,
+ otp,
+}: {
+ onboardingUrl: string
+ otp: string
+}) {
+ return (
+ <E.Html lang="en" dir="ltr">
+ <E.Container>
+ <h1>
+ <E.Text>Welcome to Epic Notes!</E.Text>
+ </h1>
+ <p>
+ <E.Text>
+ Here's your verification code: <strong>{otp}</strong>
+ </E.Text>
+ </p>
+ <p>
+ <E.Text>Or click the link to get started:</E.Text>
+ </p>
+ <E.Link href={onboardingUrl}>{onboardingUrl}</E.Link>
+ </E.Container>
+ </E.Html>
+ )
}
export const meta: V2_MetaFunction = () => {
- return [{ title: 'Setup Epic Notes Account' }]
+ return [{ title: 'Sign Up | Epic Notes' }]
}
export default function SignupRoute() {
const actionData = useActionData<typeof action>()
const isPending = useIsPending()
+ const isGitHubSubmitting = useIsPending({ formAction: '/auth/github' })
const [searchParams] = useSearchParams()
const redirectTo = searchParams.get('redirectTo')
const [form, fields] = useForm({
id: 'signup-form',
- constraint: getFieldsetConstraint(SignupFormSchema),
- defaultValue: { redirectTo },
+ constraint: getFieldsetConstraint(SignupSchema),
lastSubmission: actionData?.submission,
onValidate({ formData }) {
- return parse(formData, { schema: SignupFormSchema })
+ const result = parse(formData, { schema: SignupSchema })
+ return result
},
shouldRevalidate: 'onBlur',
})
return (
- <div className="container flex min-h-full flex-col justify-center pb-32 pt-20">
- <div className="mx-auto w-full max-w-lg">
- <div className="flex flex-col gap-3 text-center">
- <h1 className="text-h1">Welcome aboard!</h1>
- <p className="text-body-md text-muted-foreground">
- Please enter your details.
- </p>
- </div>
- <Spacer size="xs" />
- <Form
- method="POST"
- className="mx-auto min-w-[368px] max-w-sm"
- {...form.props}
- >
- <Field
- labelProps={{ htmlFor: fields.email.id, children: 'Email' }}
- inputProps={{
- ...conform.input(fields.email),
- autoComplete: 'email',
- autoFocus: true,
- className: 'lowercase',
- }}
- errors={fields.email.errors}
- />
- <Field
- labelProps={{ htmlFor: fields.username.id, children: 'Username' }}
- inputProps={{
- ...conform.input(fields.username),
- autoComplete: 'username',
- className: 'lowercase',
- }}
- errors={fields.username.errors}
- />
- <Field
- labelProps={{ htmlFor: fields.name.id, children: 'Name' }}
- inputProps={{
- ...conform.input(fields.name),
- autoComplete: 'name',
- }}
- errors={fields.name.errors}
- />
- <Field
- labelProps={{ htmlFor: fields.password.id, children: 'Password' }}
- inputProps={{
- ...conform.input(fields.password, { type: 'password' }),
- autoComplete: 'new-password',
- }}
- errors={fields.password.errors}
- />
-
+ <div className="container flex flex-col justify-center pb-32 pt-20">
+ <div className="text-center">
+ <h1 className="text-h1">Let's start your journey!</h1>
+ <p className="mt-3 text-body-md text-muted-foreground">
+ Please enter your email.
+ </p>
+ </div>
+ <div className="mx-auto mt-16 min-w-[368px] max-w-sm">
+ <Form method="POST" {...form.props}>
<Field
labelProps={{
- htmlFor: fields.confirmPassword.id,
- children: 'Confirm Password',
- }}
- inputProps={{
- ...conform.input(fields.confirmPassword, { type: 'password' }),
- autoComplete: 'new-password',
- }}
- errors={fields.confirmPassword.errors}
- />
-
- <CheckboxField
- labelProps={{
- htmlFor: fields.agreeToTermsOfServiceAndPrivacyPolicy.id,
- children:
- 'Do you agree to our Terms of Service and Privacy Policy?',
+ htmlFor: fields.email.id,
+ children: 'Email',
}}
- buttonProps={conform.input(
- fields.agreeToTermsOfServiceAndPrivacyPolicy,
- { type: 'checkbox' },
- )}
- errors={fields.agreeToTermsOfServiceAndPrivacyPolicy.errors}
- />
- <CheckboxField
- labelProps={{
- htmlFor: fields.remember.id,
- children: 'Remember me',
- }}
- buttonProps={conform.input(fields.remember, { type: 'checkbox' })}
- errors={fields.remember.errors}
+ inputProps={{ ...conform.input(fields.email), autoFocus: true }}
+ errors={fields.email.errors}
/>
-
- <input {...conform.input(fields.redirectTo)} type="hidden" />
<ErrorList errors={form.errors} id={form.errorId} />
-
- <div className="flex items-center justify-between gap-6">
- <StatusButton
- className="w-full"
- status={isPending ? 'pending' : actionData?.status ?? 'idle'}
- type="submit"
- disabled={isPending}
- >
- Create an account
- </StatusButton>
- </div>
+ <StatusButton
+ className="w-full"
+ status={isPending ? 'pending' : actionData?.status ?? 'idle'}
+ type="submit"
+ disabled={isPending}
+ >
+ Submit
+ </StatusButton>
+ </Form>
+ <Form
+ className="mt-5 flex items-center justify-center gap-2 border-t-2 border-border pt-3"
+ action="/auth/github"
+ method="POST"
+ >
+ <input type="hidden" name="redirectTo" value={redirectTo ?? '/'} />
+ <StatusButton
+ type="submit"
+ className="w-full"
+ status={isGitHubSubmitting ? 'pending' : 'idle'}
+ >
+ Sign up with GitHub
+ </StatusButton>
</Form>
</div>
</div>
)
}
+
+export function ErrorBoundary() {
+ return <GeneralErrorBoundary />
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/verify.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/verify.tsx
new file mode 100644
index 0000000..12355bd
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/verify.tsx
@@ -0,0 +1,331 @@
+import { conform, useForm, type Submission } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { generateTOTP, verifyTOTP } from '@epic-web/totp'
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import {
+ Form,
+ useActionData,
+ useLoaderData,
+ useSearchParams,
+} from '@remix-run/react'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'
+import { ErrorList, Field } from '~/components/forms.tsx'
+import { Spacer } from '~/components/spacer.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import { handleVerification as handleChangeEmailVerification } from '~/routes/settings+/profile.change-email.tsx'
+import { prisma } from '~/utils/db.server.ts'
+import { getDomainUrl, useIsPending } from '~/utils/misc.tsx'
+import { type twoFAVerifyVerificationType } from '../settings+/profile.two-factor.verify.tsx'
+import {
+ handleVerification as handleLoginTwoFactorVerification,
+ shouldRequestTwoFA,
+} from './login.tsx'
+import { handleVerification as handleOnboardingVerification } from './onboarding.tsx'
+import { handleVerification as handleResetPasswordVerification } from './reset-password.tsx'
+import { requireUserId } from '~/utils/auth.server.ts'
+import { twoFAVerificationType } from '../settings+/profile.two-factor.tsx'
+import { redirectWithToast } from '~/utils/toast.server.ts'
+
+export const codeQueryParam = 'code'
+export const targetQueryParam = 'target'
+export const typeQueryParam = 'type'
+export const redirectToQueryParam = 'redirectTo'
+const types = ['onboarding', 'reset-password', 'change-email', '2fa'] as const
+const VerificationTypeSchema = z.enum(types)
+export type VerificationTypes = z.infer<typeof VerificationTypeSchema>
+
+const VerifySchema = z.object({
+ [codeQueryParam]: z.string().min(6).max(6),
+ [typeQueryParam]: VerificationTypeSchema,
+ [targetQueryParam]: z.string(),
+ [redirectToQueryParam]: z.string().optional(),
+})
+
+export async function loader({ request }: DataFunctionArgs) {
+ const params = new URL(request.url).searchParams
+ if (!params.has(codeQueryParam)) {
+ // we don't want to show an error message on page load if the otp hasn't be
+ // prefilled in yet, so we'll send a response with an empty submission.
+ return json({
+ status: 'idle',
+ submission: {
+ intent: '',
+ payload: Object.fromEntries(params),
+ error: {},
+ },
+ } as const)
+ }
+ return validateRequest(request, params)
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ return validateRequest(request, await request.formData())
+}
+
+export function getRedirectToUrl({
+ request,
+ type,
+ target,
+ redirectTo,
+}: {
+ request: Request
+ type: VerificationTypes
+ 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 async function requireRecentVerification(request: Request) {
+ const userId = await requireUserId(request)
+ const shouldReverify = await shouldRequestTwoFA(request)
+ if (shouldReverify) {
+ const reqUrl = new URL(request.url)
+ const redirectUrl = getRedirectToUrl({
+ request,
+ target: userId,
+ type: twoFAVerificationType,
+ redirectTo: reqUrl.pathname + reqUrl.search,
+ })
+ throw await redirectWithToast(redirectUrl.toString(), {
+ title: 'Please Reverify',
+ description: 'Please reverify your account before proceeding',
+ })
+ }
+}
+
+export async function prepareVerification({
+ period,
+ request,
+ type,
+ target,
+}: {
+ period: number
+ request: Request
+ type: VerificationTypes
+ target: string
+}) {
+ const verifyUrl = getRedirectToUrl({ request, type, target })
+ const redirectTo = new URL(verifyUrl.toString())
+
+ const { otp, ...verificationConfig } = generateTOTP({
+ algorithm: 'SHA256',
+ period,
+ })
+ const verificationData = {
+ type,
+ target,
+ ...verificationConfig,
+ expiresAt: new Date(Date.now() + verificationConfig.period * 1000),
+ }
+ await prisma.verification.upsert({
+ where: { target_type: { target, type } },
+ create: verificationData,
+ update: verificationData,
+ })
+
+ // add the otp to the url we'll email the user.
+ verifyUrl.searchParams.set(codeQueryParam, otp)
+
+ return { otp, redirectTo, verifyUrl }
+}
+
+export type VerifyFunctionArgs = {
+ request: Request
+ submission: Submission<z.infer<typeof VerifySchema>>
+ body: FormData | URLSearchParams
+}
+
+export async function isCodeValid({
+ code,
+ type,
+ target,
+}: {
+ code: string
+ type: VerificationTypes | typeof twoFAVerifyVerificationType
+ target: string
+}) {
+ const verification = await prisma.verification.findUnique({
+ where: {
+ target_type: { target, type },
+ OR: [{ expiresAt: { gt: new Date() } }, { expiresAt: null }],
+ },
+ select: { algorithm: true, secret: true, period: true },
+ })
+ if (!verification) return false
+ const result = verifyTOTP({
+ otp: code,
+ secret: verification.secret,
+ algorithm: verification.algorithm,
+ period: verification.period,
+ })
+ if (!result) return false
+
+ return true
+}
+
+async function validateRequest(
+ request: Request,
+ body: URLSearchParams | FormData,
+) {
+ const submission = await parse(body, {
+ schema: () =>
+ VerifySchema.superRefine(async (data, ctx) => {
+ const codeIsValid = await isCodeValid({
+ code: data[codeQueryParam],
+ type: data[typeQueryParam],
+ target: data[targetQueryParam],
+ })
+ if (!codeIsValid) {
+ ctx.addIssue({
+ path: ['code'],
+ code: z.ZodIssueCode.custom,
+ message: `Invalid code`,
+ })
+ return
+ }
+ }),
+ acceptMultipleErrors: () => true,
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { value: submissionValue } = submission
+
+ async function deleteVerification() {
+ await prisma.verification.delete({
+ where: {
+ target_type: {
+ type: submissionValue[typeQueryParam],
+ target: submissionValue[targetQueryParam],
+ },
+ },
+ })
+ }
+
+ switch (submissionValue[typeQueryParam]) {
+ case 'reset-password': {
+ await deleteVerification()
+ return handleResetPasswordVerification({ request, body, submission })
+ }
+ case 'onboarding': {
+ await deleteVerification()
+ return handleOnboardingVerification({ request, body, submission })
+ }
+ case 'change-email': {
+ await deleteVerification()
+ return handleChangeEmailVerification({ request, body, submission })
+ }
+ case '2fa': {
+ return handleLoginTwoFactorVerification({ request, body, submission })
+ }
+ }
+}
+
+export default function VerifyRoute() {
+ const data = useLoaderData<typeof loader>()
+ const [searchParams] = useSearchParams()
+ const isPending = useIsPending()
+ const actionData = useActionData<typeof action>()
+ const type = VerificationTypeSchema.parse(searchParams.get(typeQueryParam))
+
+ const checkEmail = (
+ <>
+ <h1 className="text-h1">Check your email</h1>
+ <p className="mt-3 text-body-md text-muted-foreground">
+ We've sent you a code to verify your email address.
+ </p>
+ </>
+ )
+
+ const headings: Record<VerificationTypes, React.ReactNode> = {
+ onboarding: checkEmail,
+ 'reset-password': checkEmail,
+ 'change-email': checkEmail,
+ '2fa': (
+ <>
+ <h1 className="text-h1">Check your 2FA app</h1>
+ <p className="mt-3 text-body-md text-muted-foreground">
+ Please enter your 2FA code to verify your identity.
+ </p>
+ </>
+ ),
+ }
+
+ const [form, fields] = useForm({
+ id: 'verify-form',
+ constraint: getFieldsetConstraint(VerifySchema),
+ lastSubmission: actionData?.submission ?? data.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: VerifySchema })
+ },
+ defaultValue: {
+ code: searchParams.get(codeQueryParam) ?? '',
+ type,
+ target: searchParams.get(targetQueryParam) ?? '',
+ redirectTo: searchParams.get(redirectToQueryParam) ?? '',
+ },
+ })
+
+ return (
+ <div className="container flex flex-col justify-center pb-32 pt-20">
+ <div className="text-center">{headings[type]}</div>
+
+ <Spacer size="xs" />
+
+ <div className="mx-auto flex flex-col justify-center gap-1 w-72 max-w-full">
+ <div>
+ <ErrorList errors={form.errors} id={form.errorId} />
+ </div>
+ <div className="flex w-full gap-2">
+ <Form method="POST" {...form.props} className="flex-1">
+ <Field
+ labelProps={{
+ htmlFor: fields[codeQueryParam].id,
+ children: 'Code',
+ }}
+ inputProps={conform.input(fields[codeQueryParam])}
+ errors={fields[codeQueryParam].errors}
+ />
+ <input
+ {...conform.input(fields[typeQueryParam], { type: 'hidden' })}
+ />
+ <input
+ {...conform.input(fields[targetQueryParam], { type: 'hidden' })}
+ />
+ <input
+ {...conform.input(fields[redirectToQueryParam], {
+ type: 'hidden',
+ })}
+ />
+ <StatusButton
+ className="w-full"
+ status={isPending ? 'pending' : actionData?.status ?? 'idle'}
+ type="submit"
+ disabled={isPending}
+ >
+ Submit
+ </StatusButton>
+ </Form>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export function ErrorBoundary() {
+ return <GeneralErrorBoundary />
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/resources+/download-user-data.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/resources+/download-user-data.tsx
index 4f2bdaf..8319707 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/resources+/download-user-data.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/resources+/download-user-data.tsx
@@ -3,8 +3,6 @@ import { requireUserId } from '~/utils/auth.server.ts'
import { prisma } from '~/utils/db.server.ts'
import { getDomainUrl } from '~/utils/misc.tsx'
-export const ROUTE_PATH = '/resources/download-user-data'
-
export async function loader({ request }: DataFunctionArgs) {
const userId = await requireUserId(request)
const user = await prisma.user.findUniqueOrThrow({
@@ -37,7 +35,6 @@ export async function loader({ request }: DataFunctionArgs) {
},
password: false, // <-- intentionally omit password
sessions: true,
- roles: true,
},
})
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.change-email.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.change-email.tsx
new file mode 100644
index 0000000..d49c40b
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.change-email.tsx
@@ -0,0 +1,245 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import * as E from '@react-email/components'
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { Form, useActionData, useLoaderData } from '@remix-run/react'
+import { z } from 'zod'
+import { ErrorList, Field } from '~/components/forms.tsx'
+import { Icon } from '~/components/ui/icon.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import {
+ prepareVerification,
+ requireRecentVerification,
+ type VerifyFunctionArgs,
+} from '~/routes/_auth+/verify.tsx'
+import { requireUserId } from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { sendEmail } from '~/utils/email.server.ts'
+import { invariant, useIsPending } from '~/utils/misc.tsx'
+import { redirectWithToast } from '~/utils/toast.server.ts'
+import { emailSchema } from '~/utils/user-validation.ts'
+import { verifySessionStorage } from '~/utils/verification.server.ts'
+
+export const handle = {
+ breadcrumb: <Icon name="envelope-closed">Change Email</Icon>,
+}
+
+const newEmailAddressSessionKey = 'new-email-address'
+
+export async function handleVerification({
+ request,
+ submission,
+}: VerifyFunctionArgs) {
+ await requireRecentVerification(request)
+ invariant(submission.value, 'submission.value should be defined by now')
+
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const newEmail = verifySession.get(newEmailAddressSessionKey)
+ if (!newEmail) {
+ submission.error[''] = [
+ 'You must submit the code on the same device that requested the email change.',
+ ]
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+ const preUpdateUser = await prisma.user.findFirstOrThrow({
+ select: { email: true },
+ where: { id: submission.value.target },
+ })
+ const user = await prisma.user.update({
+ where: { id: submission.value.target },
+ select: { id: true, email: true, username: true },
+ data: { email: newEmail },
+ })
+
+ void sendEmail({
+ to: preUpdateUser.email,
+ subject: 'Epic Stack email changed',
+ react: <EmailChangeNoticeEmail userId={user.id} />,
+ })
+
+ return redirectWithToast(
+ '/settings/profile',
+ {
+ title: 'Email Changed',
+ type: 'success',
+ description: `Your email has been changed to ${user.email}`,
+ },
+ {
+ headers: {
+ 'set-cookie': await verifySessionStorage.destroySession(verifySession),
+ },
+ },
+ )
+}
+
+const ChangeEmailSchema = z.object({
+ email: emailSchema,
+})
+
+export async function loader({ request }: DataFunctionArgs) {
+ await requireRecentVerification(request)
+ const userId = await requireUserId(request)
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { email: true },
+ })
+ if (!user) {
+ const params = new URLSearchParams({ redirectTo: request.url })
+ throw redirect(`/login?${params}`)
+ }
+ return json({ user })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ schema: ChangeEmailSchema.superRefine(async (data, ctx) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { email: data.email },
+ })
+ if (existingUser) {
+ ctx.addIssue({
+ path: ['email'],
+ code: 'custom',
+ message: 'This email is already in use.',
+ })
+ }
+ }),
+ async: true,
+ acceptMultipleErrors: () => true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+ const { otp, redirectTo, verifyUrl } = await prepareVerification({
+ period: 10 * 60,
+ request,
+ target: userId,
+ type: 'change-email',
+ })
+
+ const response = await sendEmail({
+ to: submission.value.email,
+ subject: `Epic Notes Email Change Verification`,
+ react: <EmailChangeEmail verifyUrl={verifyUrl.toString()} otp={otp} />,
+ })
+
+ if (response.status === 'success') {
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ verifySession.set(newEmailAddressSessionKey, submission.value.email)
+ return redirect(redirectTo.toString(), {
+ headers: {
+ 'set-cookie': await verifySessionStorage.commitSession(verifySession),
+ },
+ })
+ } else {
+ submission.error[''] = response.error.message
+ return json({ status: 'error', submission } as const, { status: 500 })
+ }
+}
+
+export function EmailChangeEmail({
+ verifyUrl,
+ otp,
+}: {
+ verifyUrl: string
+ otp: string
+}) {
+ return (
+ <E.Html lang="en" dir="ltr">
+ <E.Container>
+ <h1>
+ <E.Text>Epic Notes Email Change</E.Text>
+ </h1>
+ <p>
+ <E.Text>
+ Here's your verification code: <strong>{otp}</strong>
+ </E.Text>
+ </p>
+ <p>
+ <E.Text>Or click the link:</E.Text>
+ </p>
+ <E.Link href={verifyUrl}>{verifyUrl}</E.Link>
+ </E.Container>
+ </E.Html>
+ )
+}
+
+export function EmailChangeNoticeEmail({ userId }: { userId: string }) {
+ return (
+ <E.Html lang="en" dir="ltr">
+ <E.Container>
+ <h1>
+ <E.Text>Your Epic Notes email has been changed</E.Text>
+ </h1>
+ <p>
+ <E.Text>
+ We're writing to let you know that your Epic Notes email has been
+ changed.
+ </E.Text>
+ </p>
+ <p>
+ <E.Text>
+ If you changed your email address, then you can safely ignore this.
+ But if you did not change your email address, then please contact
+ support immediately.
+ </E.Text>
+ </p>
+ <p>
+ <E.Text>Your Account ID: {userId}</E.Text>
+ </p>
+ </E.Container>
+ </E.Html>
+ )
+}
+
+export default function ChangeEmailIndex() {
+ const data = useLoaderData<typeof loader>()
+ const actionData = useActionData<typeof action>()
+
+ const [form, fields] = useForm({
+ id: 'change-email-form',
+ constraint: getFieldsetConstraint(ChangeEmailSchema),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: ChangeEmailSchema })
+ },
+ })
+
+ const isPending = useIsPending()
+ return (
+ <div>
+ <h1 className="text-h1">Change Email</h1>
+ <p>You will receive an email at the new email address to confirm.</p>
+ <p>
+ An email notice will also be sent to your old address {data.user.email}.
+ </p>
+ <div className="mx-auto mt-5 max-w-sm">
+ <Form method="POST" {...form.props}>
+ <Field
+ labelProps={{ children: 'New Email' }}
+ inputProps={conform.input(fields.email)}
+ errors={fields.email.errors}
+ />
+ <ErrorList id={form.errorId} errors={form.errors} />
+ <div>
+ <StatusButton
+ status={isPending ? 'pending' : actionData?.status ?? 'idle'}
+ >
+ Send Confirmation
+ </StatusButton>
+ </div>
+ </Form>
+ </div>
+ </div>
+ )
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.connections.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.connections.tsx
new file mode 100644
index 0000000..da99fb1
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.connections.tsx
@@ -0,0 +1,203 @@
+import {
+ json,
+ type DataFunctionArgs,
+ type SerializeFrom,
+} from '@remix-run/node'
+import { Form, useFetcher, useLoaderData } from '@remix-run/react'
+import { z } from 'zod'
+import { Icon } from '~/components/ui/icon.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '~/components/ui/tooltip.tsx'
+import { requireUserId } from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { invariantResponse, useIsPending } from '~/utils/misc.tsx'
+import { createToastHeaders } from '~/utils/toast.server.ts'
+
+export const handle = {
+ breadcrumb: <Icon name="link-2">Connections</Icon>,
+}
+
+const GitHubUserSchema = z.object({
+ login: z.string(),
+})
+
+async function userCanDeleteConnections(userId: string) {
+ const user = await prisma.user.findUnique({
+ select: {
+ password: { select: { userId: true } },
+ _count: { select: { gitHubConnections: true } },
+ },
+ where: { id: userId },
+ })
+ // user can delete their connections if they have a password
+ if (user?.password) return true
+ // users have to have more than one remaining connection to delete one
+ return Boolean(
+ user?._count.gitHubConnections && user?._count.gitHubConnections > 1,
+ )
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const rawGitHubConnections = await prisma.gitHubConnection.findMany({
+ select: { id: true, providerId: true, createdAt: true },
+ where: { userId },
+ })
+ const githubConnections: Array<{
+ id: string
+ username: string
+ createdAtFormatted: string
+ }> = []
+ for (const connection of rawGitHubConnections) {
+ const response = await fetch(
+ `https://api.github.com/user/${connection.providerId}`,
+ {
+ headers: {
+ Authorization: `token ${process.env.GITHUB_TOKEN}`,
+ },
+ },
+ )
+ const rawJson = await response.json()
+ const result = GitHubUserSchema.safeParse(rawJson)
+ githubConnections.push({
+ id: connection.id,
+ username: result.success ? result.data.login : 'Unknown',
+ createdAtFormatted: connection.createdAt.toLocaleString(),
+ })
+ }
+
+ return json({
+ githubConnections,
+ canDeleteConnections: await userCanDeleteConnections(userId),
+ })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const formData = await request.formData()
+ invariantResponse(
+ formData.get('intent') === 'delete-connection',
+ 'Invalid intent',
+ )
+ invariantResponse(
+ await userCanDeleteConnections(userId),
+ 'You cannot delete your last connection unless you have a password.',
+ )
+ const connectionId = formData.get('connectionId')
+ invariantResponse(typeof connectionId === 'string', 'Invalid connectionId')
+ await prisma.gitHubConnection.delete({
+ where: {
+ id: connectionId,
+ userId: userId,
+ },
+ })
+ const toastHeaders = await createToastHeaders({
+ title: 'Deleted',
+ description: 'Your connection has been deleted.',
+ })
+ return json({ status: 'success' } as const, { headers: toastHeaders })
+}
+
+export default function Connections() {
+ const data = useLoaderData<typeof loader>()
+ const isGitHubSubmitting = useIsPending({ formAction: '/auth/github' })
+
+ return (
+ <div className="max-w-md mx-auto">
+ {data.githubConnections.length ? (
+ <div className="flex gap-2 flex-col">
+ <p>Here are your current connections:</p>
+ <ul className="flex flex-col gap-4">
+ {data.githubConnections.map(c => (
+ <li key={c.id}>
+ <Connection
+ connection={c}
+ canDelete={data.canDeleteConnections}
+ />
+ </li>
+ ))}
+ </ul>
+ </div>
+ ) : (
+ <p>You don't have any connections yet.</p>
+ )}
+ <Form
+ className="mt-5 flex items-center justify-center gap-2 border-t-2 border-border pt-3"
+ action="/auth/github"
+ method="POST"
+ >
+ <StatusButton
+ type="submit"
+ className="w-full"
+ status={isGitHubSubmitting ? 'pending' : 'idle'}
+ >
+ <Icon name="github-logo">Connect with GitHub</Icon>
+ </StatusButton>
+ </Form>
+ </div>
+ )
+}
+
+function Connection({
+ connection,
+ canDelete,
+}: {
+ connection: SerializeFrom<typeof loader>['githubConnections'][number]
+ canDelete: boolean
+}) {
+ const deleteFetcher = useFetcher<typeof action>()
+ return (
+ <div className="flex gap-2 justify-between">
+ <Icon name="github-logo">
+ <a
+ href={`https://github.com/${connection.username}`}
+ className="underline"
+ >
+ {connection.username}
+ </a>{' '}
+ ({connection.createdAtFormatted})
+ </Icon>
+ {canDelete ? (
+ <deleteFetcher.Form method="POST">
+ <input name="connectionId" value={connection.id} type="hidden" />
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <StatusButton
+ name="intent"
+ value="delete-connection"
+ variant="destructive"
+ size="sm"
+ status={
+ deleteFetcher.state !== 'idle'
+ ? 'pending'
+ : deleteFetcher.data?.status ?? 'idle'
+ }
+ >
+ <Icon name="cross-1" />
+ </StatusButton>
+ </TooltipTrigger>
+ <TooltipContent>Disconnect this account</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </deleteFetcher.Form>
+ ) : (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Icon name="question-mark-circled"></Icon>
+ </TooltipTrigger>
+ <TooltipContent>
+ You cannot delete your last connection unless you have a password.
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
+ )
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/settings+/profile.index.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.index.tsx
index ffa4433..032714b 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/settings+/profile.index.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.index.tsx
@@ -14,22 +14,18 @@ import {
invariantResponse,
useDoubleCheck,
} from '~/utils/misc.tsx'
-import { getSession } from '~/utils/session.server.ts'
-import {
- emailSchema,
- nameSchema,
- usernameSchema,
-} from '~/utils/user-validation.ts'
+import { sessionStorage } from '~/utils/session.server.ts'
+import { nameSchema, usernameSchema } from '~/utils/user-validation.ts'
+import { twoFAVerificationType } from './profile.two-factor.tsx'
const ProfileFormSchema = z.object({
name: nameSchema.optional(),
username: usernameSchema,
- email: emailSchema,
})
export async function loader({ request }: DataFunctionArgs) {
const userId = await requireUserId(request)
- const user = await prisma.user.findUnique({
+ const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
id: true,
@@ -40,14 +36,32 @@ export async function loader({ request }: DataFunctionArgs) {
select: { id: true },
},
_count: {
- select: { sessions: true },
+ select: {
+ sessions: {
+ where: {
+ expirationDate: { gt: new Date() },
+ },
+ },
+ },
},
},
})
- invariantResponse(user, 'User not found', { status: 404 })
+ const twoFactorVerification = await prisma.verification.findUnique({
+ select: { id: true },
+ where: { target_type: { type: twoFAVerificationType, target: userId } },
+ })
+
+ const password = await prisma.password.findUnique({
+ select: { userId: true },
+ where: { userId },
+ })
- return json({ user })
+ return json({
+ user,
+ hasPassword: Boolean(password),
+ isTwoFactorEnabled: Boolean(twoFactorVerification),
+ })
}
type ProfileActionArgs = {
@@ -112,8 +126,31 @@ export default function EditUserProfile() {
<div className="col-span-6 mb-12 mt-6 h-1 border-b-[1.5px]" />
<div className="col-span-full flex flex-col gap-6">
<div>
- <Link to="password">
- <Icon name="dots-horizontal">Change Password</Icon>
+ <Link to="change-email">
+ <Icon name="envelope-closed">
+ Change email from {data.user.email}
+ </Icon>
+ </Link>
+ </div>
+ <div>
+ <Link to="two-factor">
+ {data.isTwoFactorEnabled ? (
+ <Icon name="lock-closed">2FA is enabled</Icon>
+ ) : (
+ <Icon name="lock-open-1">Enable 2FA</Icon>
+ )}
+ </Link>
+ </div>
+ <div>
+ <Link to={data.hasPassword ? 'password' : 'password/create'}>
+ <Icon name="dots-horizontal">
+ {data.hasPassword ? 'Change Password' : 'Create a Password'}
+ </Icon>
+ </Link>
+ </div>
+ <div>
+ <Link to="connections">
+ <Icon name="link-2">Manage connections</Icon>
</Link>
</div>
<div>
@@ -134,7 +171,7 @@ export default function EditUserProfile() {
async function profileUpdateAction({ userId, formData }: ProfileActionArgs) {
const submission = await parse(formData, {
async: true,
- schema: ProfileFormSchema.superRefine(async ({ email, username }, ctx) => {
+ schema: ProfileFormSchema.superRefine(async ({ username }, ctx) => {
const existingUsername = await prisma.user.findUnique({
where: { username },
select: { id: true },
@@ -146,17 +183,6 @@ async function profileUpdateAction({ userId, formData }: ProfileActionArgs) {
message: 'A user already exists with this username',
})
}
- const existingEmail = await prisma.user.findUnique({
- where: { email },
- select: { id: true },
- })
- if (existingEmail && existingEmail.id !== userId) {
- ctx.addIssue({
- path: ['email'],
- code: 'custom',
- message: 'A user already exists with this email',
- })
- }
}),
})
if (submission.intent !== 'submit') {
@@ -174,7 +200,6 @@ async function profileUpdateAction({ userId, formData }: ProfileActionArgs) {
data: {
name: data.name,
username: data.username,
- email: data.email,
},
})
@@ -218,12 +243,6 @@ function UpdateProfile() {
inputProps={conform.input(fields.name)}
errors={fields.name.errors}
/>
- <Field
- className="col-span-3"
- labelProps={{ htmlFor: fields.email.id, children: 'Email' }}
- inputProps={conform.input(fields.email)}
- errors={fields.email.errors}
- />
</div>
<ErrorList errors={form.errors} id={form.errorId} />
@@ -248,7 +267,9 @@ function UpdateProfile() {
}
async function signOutOfSessionsAction({ request, userId }: ProfileActionArgs) {
- const cookieSession = await getSession(request.headers.get('cookie'))
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
const sessionId = cookieSession.get(sessionKey)
invariantResponse(
sessionId,
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/settings+/profile.password.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.password.tsx
index 0c7e30a..8a4c76e 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/settings+/profile.password.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.password.tsx
@@ -36,8 +36,25 @@ const ChangePasswordForm = z
}
})
+async function requirePassword(userId: string) {
+ const password = await prisma.password.findUnique({
+ select: { userId: true },
+ where: { userId },
+ })
+ if (!password) {
+ throw redirect('/settings/profile/password/create')
+ }
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ await requirePassword(userId)
+ return json({})
+}
+
export async function action({ request }: DataFunctionArgs) {
const userId = await requireUserId(request)
+ await requirePassword(userId)
const formData = await request.formData()
const submission = await parse(formData, {
async: true,
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.password_.create.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.password_.create.tsx
new file mode 100644
index 0000000..45197b3
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.password_.create.tsx
@@ -0,0 +1,128 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { Form, Link, useActionData } from '@remix-run/react'
+import { z } from 'zod'
+import { ErrorList, Field } from '~/components/forms.tsx'
+import { Button } from '~/components/ui/button.tsx'
+import { Icon } from '~/components/ui/icon.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import { getPasswordHash, requireUserId } from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { useIsPending } from '~/utils/misc.tsx'
+import { passwordSchema } from '~/utils/user-validation.ts'
+
+export const handle = {
+ breadcrumb: <Icon name="dots-horizontal">Password</Icon>,
+}
+
+const CreatePasswordForm = z
+ .object({
+ newPassword: passwordSchema,
+ confirmNewPassword: passwordSchema,
+ })
+ .superRefine(({ confirmNewPassword, newPassword }, ctx) => {
+ if (confirmNewPassword !== newPassword) {
+ ctx.addIssue({
+ path: ['confirmNewPassword'],
+ code: 'custom',
+ message: 'The passwords must match',
+ })
+ }
+ })
+
+async function requireNoPassword(userId: string) {
+ const password = await prisma.password.findUnique({
+ select: { userId: true },
+ where: { userId },
+ })
+ if (password) {
+ throw redirect('/settings/profile/password')
+ }
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ await requireNoPassword(userId)
+ return json({})
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ await requireNoPassword(userId)
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ async: true,
+ schema: CreatePasswordForm,
+ })
+ // clear the payload so we don't send the password back to the client
+ submission.payload = {}
+ if (submission.intent !== 'submit') {
+ // clear the value so we don't send the password back to the client
+ submission.value = undefined
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { newPassword } = submission.value
+
+ await prisma.user.update({
+ select: { username: true },
+ where: { id: userId },
+ data: {
+ password: {
+ create: {
+ hash: await getPasswordHash(newPassword),
+ },
+ },
+ },
+ })
+
+ return redirect(`/settings/profile`, { status: 302 })
+}
+
+export default function CreatePasswordRoute() {
+ const actionData = useActionData<typeof action>()
+ const isPending = useIsPending()
+
+ const [form, fields] = useForm({
+ id: 'signup-form',
+ constraint: getFieldsetConstraint(CreatePasswordForm),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: CreatePasswordForm })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+ <Form method="POST" {...form.props} className="max-w-md mx-auto">
+ <Field
+ labelProps={{ children: 'New Password' }}
+ inputProps={conform.input(fields.newPassword, { type: 'password' })}
+ errors={fields.newPassword.errors}
+ />
+ <Field
+ labelProps={{ children: 'Confirm New Password' }}
+ inputProps={conform.input(fields.confirmNewPassword, {
+ type: 'password',
+ })}
+ errors={fields.confirmNewPassword.errors}
+ />
+ <ErrorList id={form.errorId} errors={form.errors} />
+ <div className="w-full grid grid-cols-2 gap-6">
+ <Button variant="secondary" asChild>
+ <Link to="..">Cancel</Link>
+ </Button>
+ <StatusButton
+ type="submit"
+ status={isPending ? 'pending' : actionData?.status ?? 'idle'}
+ >
+ Create Password
+ </StatusButton>
+ </div>
+ </Form>
+ )
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.disable.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.disable.tsx
new file mode 100644
index 0000000..96f8992
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.disable.tsx
@@ -0,0 +1,59 @@
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import { useFetcher } from '@remix-run/react'
+import { Icon } from '~/components/ui/icon.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import { requireRecentVerification } from '~/routes/_auth+/verify.tsx'
+import { requireUserId } from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { useDoubleCheck } from '~/utils/misc.tsx'
+import { redirectWithToast } from '~/utils/toast.server.ts'
+import { twoFAVerificationType } from './profile.two-factor.tsx'
+
+export const handle = {
+ breadcrumb: <Icon name="lock-open-1">Disable</Icon>,
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ await requireRecentVerification(request)
+ return json({})
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ await requireRecentVerification(request)
+ const userId = await requireUserId(request)
+ await prisma.verification.delete({
+ where: { target_type: { target: userId, type: twoFAVerificationType } },
+ })
+ return redirectWithToast('/settings/profile/two-factor', {
+ title: '2FA Disabled',
+ description: 'Two factor authentication has been disabled.',
+ })
+}
+
+export default function TwoFactorDisableRoute() {
+ const disable2FAFetcher = useFetcher<typeof action>()
+ const dc = useDoubleCheck()
+
+ return (
+ <div className="mx-auto max-w-sm">
+ <disable2FAFetcher.Form method="POST" preventScrollReset>
+ <p>
+ Disabling two factor authentication is not recommended. However, if
+ you would like to do so, click here:
+ </p>
+ <StatusButton
+ variant="destructive"
+ status={disable2FAFetcher.state === 'loading' ? 'pending' : 'idle'}
+ {...dc.getButtonProps({
+ className: 'mx-auto',
+ name: 'intent',
+ value: 'disable',
+ type: 'submit',
+ })}
+ >
+ {dc.doubleCheck ? 'Are you sure?' : 'Disable 2FA'}
+ </StatusButton>
+ </disable2FAFetcher.Form>
+ </div>
+ )
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.index.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.index.tsx
new file mode 100644
index 0000000..5c4a641
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.index.tsx
@@ -0,0 +1,86 @@
+import { generateTOTP } from '@epic-web/totp'
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { Link, useFetcher, useLoaderData } from '@remix-run/react'
+import { Icon } from '~/components/ui/icon.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import { requireUserId } from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { twoFAVerificationType } from './profile.two-factor.tsx'
+import { twoFAVerifyVerificationType as twoFAVerifyVerificationType } from './profile.two-factor.verify.tsx'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const verification = await prisma.verification.findUnique({
+ where: { target_type: { type: twoFAVerificationType, target: userId } },
+ select: { id: true },
+ })
+ return json({ is2FAEnabled: Boolean(verification) })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const { otp: _otp, ...config } = generateTOTP()
+ const verificationData = {
+ ...config,
+ type: twoFAVerifyVerificationType,
+ target: userId,
+ }
+ await prisma.verification.upsert({
+ where: {
+ target_type: { target: userId, type: twoFAVerifyVerificationType },
+ },
+ create: verificationData,
+ update: verificationData,
+ })
+ return redirect('/settings/profile/two-factor/verify')
+}
+
+export default function TwoFactorRoute() {
+ const data = useLoaderData<typeof loader>()
+ const enable2FAFetcher = useFetcher<typeof action>()
+
+ return (
+ <div className="flex flex-col gap-4">
+ {data.is2FAEnabled ? (
+ <>
+ <p className="text-lg">
+ <Icon name="check">
+ You have enabled two-factor authentication.
+ </Icon>
+ </p>
+ <Link to="disable">
+ <Icon name="lock-open-1">Disable 2FA</Icon>
+ </Link>
+ </>
+ ) : (
+ <>
+ <p>
+ <Icon name="lock-open-1">
+ You have not enabled two-factor authentication yet.
+ </Icon>
+ </p>
+ <p className="text-sm">
+ Two factor authentication adds an extra layer of security to your
+ account. You will need to enter a code from an authenticator app
+ like{' '}
+ <a className="underline" href="https://1password.com/">
+ 1Password
+ </a>{' '}
+ to log in.
+ </p>
+ <enable2FAFetcher.Form method="POST" preventScrollReset>
+ <StatusButton
+ type="submit"
+ name="intent"
+ value="enable"
+ status={enable2FAFetcher.state === 'loading' ? 'pending' : 'idle'}
+ className="mx-auto"
+ >
+ Enable 2FA
+ </StatusButton>
+ </enable2FAFetcher.Form>
+ </>
+ )}
+ </div>
+ )
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.tsx
new file mode 100644
index 0000000..e52a751
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.tsx
@@ -0,0 +1,13 @@
+import { Outlet } from '@remix-run/react'
+import { Icon } from '~/components/ui/icon.tsx'
+import { type VerificationTypes } from '~/routes/_auth+/verify.tsx'
+
+export const handle = {
+ breadcrumb: <Icon name="lock-closed">2FA</Icon>,
+}
+
+export const twoFAVerificationType = '2fa' satisfies VerificationTypes
+
+export default function TwoFactorRoute() {
+ return <Outlet />
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.verify.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.verify.tsx
new file mode 100644
index 0000000..da2c2e3
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.verify.tsx
@@ -0,0 +1,172 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { getTOTPAuthUri } from '@epic-web/totp'
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { Form, useActionData, useLoaderData } from '@remix-run/react'
+import * as QRCode from 'qrcode'
+import { z } from 'zod'
+import { Field } from '~/components/forms.tsx'
+import { Icon } from '~/components/ui/icon.tsx'
+import { StatusButton } from '~/components/ui/status-button.tsx'
+import { isCodeValid } from '~/routes/_auth+/verify.tsx'
+import { requireUserId } from '~/utils/auth.server.ts'
+import { prisma } from '~/utils/db.server.ts'
+import { getDomainUrl, useIsPending } from '~/utils/misc.tsx'
+import { redirectWithToast } from '~/utils/toast.server.ts'
+import { twoFAVerificationType } from './profile.two-factor.tsx'
+
+export const handle = {
+ breadcrumb: <Icon name="check">Verify</Icon>,
+}
+
+const VerifySchema = z.object({
+ code: z.string().min(6).max(6),
+})
+
+export const twoFAVerifyVerificationType = '2fa-verify'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const verification = await prisma.verification.findUnique({
+ where: {
+ target_type: { type: twoFAVerifyVerificationType, target: userId },
+ },
+ select: {
+ id: true,
+ algorithm: true,
+ secret: true,
+ period: true,
+ digits: true,
+ },
+ })
+ if (!verification) {
+ return redirect('/settings/profile/two-factor')
+ }
+ const user = await prisma.user.findUniqueOrThrow({
+ where: { id: userId },
+ select: { email: true },
+ })
+ const issuer = new URL(getDomainUrl(request)).host
+ const otpUri = getTOTPAuthUri({
+ ...verification,
+ accountName: user.email,
+ issuer,
+ })
+ const qrCode = await QRCode.toDataURL(otpUri)
+ return json({ otpUri, qrCode })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const formData = await request.formData()
+
+ if (formData.get('intent') === 'cancel') {
+ await prisma.verification.deleteMany({
+ where: { type: twoFAVerifyVerificationType, target: userId },
+ })
+ return redirect('/settings/profile/two-factor')
+ }
+ const submission = await parse(formData, {
+ schema: () =>
+ VerifySchema.superRefine(async (data, ctx) => {
+ const codeIsValid = await isCodeValid({
+ code: data.code,
+ type: twoFAVerifyVerificationType,
+ target: userId,
+ })
+ if (!codeIsValid) {
+ ctx.addIssue({
+ path: ['code'],
+ code: z.ZodIssueCode.custom,
+ message: `Invalid code`,
+ })
+ return
+ }
+ }),
+ acceptMultipleErrors: () => true,
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ await prisma.verification.update({
+ where: {
+ target_type: { type: twoFAVerifyVerificationType, target: userId },
+ },
+ data: { type: twoFAVerificationType },
+ })
+ return redirectWithToast('/settings/profile/two-factor', {
+ type: 'success',
+ title: 'Enabled',
+ description: 'Two-factor authentication has been enabled.',
+ })
+}
+
+export default function TwoFactorRoute() {
+ const data = useLoaderData<typeof loader>()
+ const actionData = useActionData<typeof action>()
+
+ const isPending = useIsPending()
+
+ const [form, fields] = useForm({
+ id: 'verify-form',
+ constraint: getFieldsetConstraint(VerifySchema),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: VerifySchema })
+ },
+ })
+
+ return (
+ <div>
+ <div className="flex flex-col items-center gap-4">
+ <img alt="qr code" src={data.qrCode} className="h-56 w-56" />
+ <p>Scan this QR code with your authenticator app.</p>
+ <p className="text-sm">
+ If you cannot scan the QR code, you can manually add this account to
+ your authenticator app using this code:
+ </p>
+ <div className="p-3">
+ <pre
+ className="whitespace-pre-wrap break-all text-sm"
+ aria-label="One-time Password URI"
+ >
+ {data.otpUri}
+ </pre>
+ </div>
+ <p className="text-sm">
+ Once you've added the account, enter the code from your authenticator
+ app below. Once you enable 2FA, you will need to enter a code from
+ your authenticator app every time you log in or perform important
+ actions. Do not lose access to your authenticator app, or you will
+ lose access to your account.
+ </p>
+ <div className="flex w-full max-w-xs flex-col justify-center gap-4">
+ <Form method="POST" {...form.props} className="flex-1">
+ <Field
+ labelProps={{
+ htmlFor: fields.code.id,
+ children: 'Code',
+ }}
+ inputProps={{ ...conform.input(fields.code), autoFocus: true }}
+ errors={fields.code.errors}
+ />
+ <StatusButton
+ className="w-full"
+ status={isPending ? 'pending' : actionData?.status ?? 'idle'}
+ type="submit"
+ disabled={isPending}
+ >
+ Submit
+ </StatusButton>
+ </Form>
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/users+/$username_+/notes.$noteId.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/users+/$username_+/notes.$noteId.tsx
index 13db857..151ecf7 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/routes/users+/$username_+/notes.$noteId.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/users+/$username_+/notes.$noteId.tsx
@@ -1,6 +1,6 @@
import { useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
-import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { json, type DataFunctionArgs } from '@remix-run/node'
import {
Form,
Link,
@@ -16,6 +16,7 @@ import { ErrorList } from '~/components/forms.tsx'
import { Button } from '~/components/ui/button.tsx'
import { Icon } from '~/components/ui/icon.tsx'
import { StatusButton } from '~/components/ui/status-button.tsx'
+import { requireUserId } from '~/utils/auth.server.ts'
import { prisma } from '~/utils/db.server.ts'
import {
getNoteImgSrc,
@@ -26,9 +27,9 @@ import {
requireUserWithPermission,
userHasPermission,
} from '~/utils/permissions.ts'
+import { redirectWithToast } from '~/utils/toast.server.ts'
import { useOptionalUser } from '~/utils/user.ts'
import { type loader as notesLoader } from './notes.tsx'
-import { requireUserId } from '~/utils/auth.server.ts'
export async function loader({ params }: DataFunctionArgs) {
const note = await prisma.note.findUnique({
@@ -89,12 +90,16 @@ export async function action({ request }: DataFunctionArgs) {
const isOwner = note.ownerId === userId
await requireUserWithPermission(
request,
- isOwner ? `delete:note:any,own` : `delete:note:any`,
+ isOwner ? `delete:note:own` : `delete:note:any`,
)
await prisma.note.delete({ where: { id: note.id } })
- return redirect(`/users/${note.owner.username}/notes`)
+ return redirectWithToast(`/users/${note.owner.username}/notes`, {
+ type: 'success',
+ title: 'Success',
+ description: 'Your note has been deleted.',
+ })
}
export default function NoteRoute() {
@@ -103,7 +108,7 @@ export default function NoteRoute() {
const isOwner = user?.id === data.note.ownerId
const canDelete = userHasPermission(
user,
- isOwner ? `delete:note:any,own` : `delete:note:any`,
+ isOwner ? `delete:note:own` : `delete:note:any`,
)
const displayBar = canDelete || isOwner
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/utils/auth.server.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/auth.server.ts
index e399f08..2e07e02 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/utils/auth.server.ts
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/auth.server.ts
@@ -1,27 +1,66 @@
-import { type Password, type User } from '@prisma/client'
+import { type GitHubConnection, type Password, type User } from '@prisma/client'
+import { redirect } from '@remix-run/node'
import bcrypt from 'bcryptjs'
+import { Authenticator } from 'remix-auth'
+import { GitHubStrategy } from 'remix-auth-github'
+import { safeRedirect } from 'remix-utils'
import { prisma } from '~/utils/db.server.ts'
-import { commitSession, getSession } from './session.server.ts'
-import { redirect } from '@remix-run/node'
+import { combineHeaders, downloadFile } from './misc.tsx'
+import { sessionStorage } from './session.server.ts'
export const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30
export const sessionKey = 'sessionId'
+export const authenticator = new Authenticator<{
+ id: string
+ email: string
+ username: string
+ name: string
+ imageUrl: string
+}>(sessionStorage)
+
+authenticator.use(
+ new GitHubStrategy(
+ {
+ clientID: process.env.GITHUB_CLIENT_ID,
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
+ callbackURL: '/auth/github/callback',
+ },
+ async ({ profile }) => {
+ const email = profile.emails[0].value.trim().toLowerCase()
+ const rawUsername = profile.displayName
+ const regex = /[^a-zA-Z0-9_]/g
+ const username = rawUsername.replace(regex, '_').toLowerCase()
+ const imageUrl = profile.photos[0].value
+ return {
+ email,
+ id: profile.id,
+ username,
+ name: profile.name.givenName,
+ imageUrl,
+ }
+ },
+ ),
+ GitHubStrategy.name,
+)
+
export async function getUserId(request: Request) {
- const cookieSession = await getSession(request.headers.get('cookie'))
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
const sessionId = cookieSession.get(sessionKey)
if (!sessionId) return null
const session = await prisma.session.findUnique({
select: { user: { select: { id: true } } },
- where: { id: sessionId },
+ where: { id: sessionId, expirationDate: { gt: new Date() } },
})
if (!session?.user) {
// Perhaps user was deleted?
cookieSession.unset(sessionKey)
throw redirect('/', {
headers: {
- 'set-cookie': await commitSession(cookieSession),
+ 'set-cookie': await sessionStorage.commitSession(cookieSession),
},
})
}
@@ -65,7 +104,7 @@ export async function login({
const user = await verifyUserPassword({ username }, password)
if (!user) return null
const session = await prisma.session.create({
- select: { id: true, expirationDate: true },
+ select: { id: true, expirationDate: true, userId: true },
data: {
expirationDate: new Date(Date.now() + SESSION_EXPIRATION_TIME),
userId: user.id,
@@ -74,6 +113,26 @@ export async function login({
return session
}
+export async function resetUserPassword({
+ username,
+ password,
+}: {
+ username: User['username']
+ password: string
+}) {
+ const hashedPassword = await bcrypt.hash(password, 10)
+ return prisma.user.update({
+ where: { username },
+ data: {
+ password: {
+ update: {
+ hash: hashedPassword,
+ },
+ },
+ },
+ })
+}
+
export async function signup({
email,
username,
@@ -95,6 +154,7 @@ export async function signup({
email: email.toLowerCase(),
username: username.toLowerCase(),
name,
+ roles: { connect: { name: 'user' } },
password: {
create: {
hash: hashedPassword,
@@ -109,11 +169,62 @@ export async function signup({
return session
}
-export async function logout(request: Request) {
- const cookieSession = await getSession(request.headers.get('cookie'))
+export async function signupWithGitHub({
+ email,
+ username,
+ name,
+ gitHubId,
+ imageUrl,
+}: {
+ email: User['email']
+ username: User['username']
+ name: User['name']
+ gitHubId: GitHubConnection['providerId']
+ imageUrl?: string
+}) {
+ const session = await prisma.session.create({
+ data: {
+ expirationDate: new Date(Date.now() + SESSION_EXPIRATION_TIME),
+ user: {
+ create: {
+ email: email.toLowerCase(),
+ username: username.toLowerCase(),
+ name,
+ gitHubConnections: { create: { providerId: gitHubId } },
+ image: imageUrl
+ ? { create: await downloadFile(imageUrl) }
+ : undefined,
+ },
+ },
+ },
+ select: { id: true, expirationDate: true },
+ })
+
+ return session
+}
+
+export async function logout(
+ {
+ request,
+ redirectTo = '/',
+ }: {
+ request: Request
+ redirectTo?: string
+ },
+ responseInit?: ResponseInit,
+) {
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const sessionId = cookieSession.get(sessionKey)
+ await prisma.session.delete({ where: { id: sessionId } })
cookieSession.unset(sessionKey)
- throw redirect('/', {
- headers: { 'set-cookie': await commitSession(cookieSession) },
+ throw redirect(safeRedirect(redirectTo), {
+ ...responseInit,
+ headers: combineHeaders(
+ { 'set-cookie': await sessionStorage.commitSession(cookieSession) },
+ responseInit?.headers,
+ ),
})
}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/utils/db.server.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/db.server.ts
index cf28baa..195cdf6 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/utils/db.server.ts
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/db.server.ts
@@ -11,6 +11,7 @@ const prisma = singleton('prisma', () => {
log: [
{ level: 'query', emit: 'event' },
{ level: 'error', emit: 'stdout' },
+ { level: 'info', emit: 'stdout' },
{ level: 'warn', emit: 'stdout' },
],
})
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/email.server.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/email.server.ts
new file mode 100644
index 0000000..23cafbc
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/email.server.ts
@@ -0,0 +1,85 @@
+import { type ReactElement } from 'react'
+import { renderAsync } from '@react-email/components'
+import { z } from 'zod'
+
+const ResendErrorSchema = z.union([
+ z.object({
+ name: z.string(),
+ message: z.string(),
+ statusCode: z.number(),
+ }),
+ z.object({
+ name: z.literal('UnknownError'),
+ message: z.literal('Unknown Error'),
+ statusCode: z.literal(500),
+ cause: z.any(),
+ }),
+])
+type ResendError = z.infer<typeof ResendErrorSchema>
+
+const ResendSuccessSchema = z.object({
+ id: z.string(),
+})
+
+export async function sendEmail({
+ react,
+ ...options
+}: {
+ to: string
+ subject: string
+} & (
+ | { html: string; text: string; react?: never }
+ | { react: ReactElement; html?: never; text?: never }
+)) {
+ const from = 'hello@epicstack.dev'
+
+ const email = {
+ from,
+ ...options,
+ ...(react ? await renderReactEmail(react) : null),
+ }
+
+ const response = await fetch('https://api.resend.com/emails', {
+ method: 'POST',
+ body: JSON.stringify(email),
+ headers: {
+ Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
+ 'Content-Type': 'application/json',
+ },
+ })
+ const data = await response.json()
+ const parsedData = ResendSuccessSchema.safeParse(data)
+
+ if (response.ok && parsedData.success) {
+ return {
+ status: 'success',
+ data: parsedData,
+ } as const
+ } else {
+ const parseResult = ResendErrorSchema.safeParse(data)
+ if (parseResult.success) {
+ return {
+ status: 'error',
+ error: parseResult.data,
+ } as const
+ } else {
+ return {
+ status: 'error',
+ error: {
+ name: 'UnknownError',
+ message: 'Unknown Error',
+ statusCode: 500,
+ cause: data,
+ } satisfies ResendError,
+ } as const
+ }
+ }
+}
+
+async function renderReactEmail(react: ReactElement) {
+ const [html, text] = await Promise.all([
+ renderAsync(react),
+ renderAsync(react, { plainText: true }),
+ ])
+ return { html, text }
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/utils/env.server.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/env.server.ts
index dab5f62..59ea590 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/utils/env.server.ts
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/env.server.ts
@@ -4,6 +4,10 @@ const schema = z.object({
NODE_ENV: z.enum(['production', 'development', 'test'] as const),
DATABASE_URL: z.string(),
SESSION_SECRET: z.string(),
+ RESEND_API_KEY: z.string(),
+ GITHUB_TOKEN: z.string(),
+ GITHUB_CLIENT_ID: z.string(),
+ GITHUB_CLIENT_SECRET: z.string(),
})
declare global {
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/utils/misc.tsx var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/misc.tsx
index eb8e3fc..2bd441e 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/utils/misc.tsx
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/misc.tsx
@@ -82,6 +82,22 @@ export function combineHeaders(...headers: Array<ResponseInit['headers']>) {
return combined
}
+/**
+ * Combine multiple response init objects into one (uses combineHeaders)
+ */
+export function combineResponseInits(
+ ...responseInits: Array<ResponseInit | undefined>
+) {
+ let combined: ResponseInit = {}
+ for (const responseInit of responseInits) {
+ combined = {
+ ...responseInit,
+ headers: combineHeaders(combined.headers, responseInit?.headers),
+ }
+ }
+ return combined
+}
+
/**
* Provide a condition and if that condition is falsey, this throws an error
* with the given message.
@@ -139,7 +155,7 @@ export function invariantResponse(
* Returns true if the current navigation is submitting the current route's
* form. Defaults to the current route's form action and method POST.
*
- * If GET, then this uses navigation.state === 'loading' instead of submitting.
+ * Defaults state to 'non-idle'
*
* NOTE: the default formAction will include query params, but the
* navigation.formAction will not, so don't use the default formAction if you
@@ -148,14 +164,20 @@ export function invariantResponse(
export function useIsPending({
formAction,
formMethod = 'POST',
+ state = 'non-idle',
}: {
formAction?: string
formMethod?: 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE'
+ state?: 'submitting' | 'loading' | 'non-idle'
} = {}) {
const contextualFormAction = useFormAction()
const navigation = useNavigation()
+ const isPendingState =
+ state === 'non-idle'
+ ? navigation.state !== 'idle'
+ : navigation.state === state
return (
- navigation.state === (formMethod === 'GET' ? 'loading' : 'submitting') &&
+ isPendingState &&
navigation.formAction === (formAction ?? contextualFormAction) &&
navigation.formMethod === formMethod
)
@@ -266,3 +288,19 @@ export function useDebounce<
[delay],
)
}
+
+export async function downloadFile(url: string, retries: number = 0) {
+ const MAX_RETRIES = 3
+ try {
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error(`Failed to fetch image with status ${response.status}`)
+ }
+ const contentType = response.headers.get('content-type') ?? 'image/jpg'
+ const blob = Buffer.from(await response.arrayBuffer())
+ return { contentType, blob }
+ } catch (e) {
+ if (retries > MAX_RETRIES) throw e
+ return downloadFile(url, retries + 1)
+ }
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/redirect-cookie.server.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/redirect-cookie.server.ts
new file mode 100644
index 0000000..0008537
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/redirect-cookie.server.ts
@@ -0,0 +1,17 @@
+import * as cookie from 'cookie'
+
+const key = 'redirectTo'
+export const destroyRedirectToHeader = cookie.serialize(key, '', { maxAge: -1 })
+
+export function getRedirectCookieHeader(redirectTo?: string) {
+ return redirectTo && redirectTo !== '/'
+ ? cookie.serialize(key, redirectTo, { maxAge: 60 * 10 })
+ : null
+}
+
+export function getRedirectCookieValue(request: Request) {
+ const rawCookie = request.headers.get('cookie')
+ const parsedCookies = rawCookie ? cookie.parse(rawCookie) : {}
+ const redirectTo = parsedCookies[key]
+ return redirectTo || null
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/utils/session.server.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/session.server.ts
index ff6a615..4f54bba 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/app/utils/session.server.ts
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/session.server.ts
@@ -1,14 +1,12 @@
import { createCookieSessionStorage } from '@remix-run/node'
-const sessionStorage = createCookieSessionStorage({
+export const sessionStorage = createCookieSessionStorage({
cookie: {
- name: '_session',
+ name: 'en_session',
sameSite: 'lax',
path: '/',
httpOnly: true,
- secrets: [process.env.SESSION_SECRET],
+ secrets: process.env.SESSION_SECRET.split(','),
secure: process.env.NODE_ENV === 'production',
},
})
-
-export const { getSession, commitSession, destroySession } = sessionStorage
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/toast.server.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/toast.server.ts
new file mode 100644
index 0000000..2604298
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/toast.server.ts
@@ -0,0 +1,63 @@
+import { createCookieSessionStorage, redirect } from '@remix-run/node'
+import { createId as cuid } from '@paralleldrive/cuid2'
+import { z } from 'zod'
+import { combineHeaders } from './misc.tsx'
+
+const toastKey = 'toast'
+
+const TypeSchema = z.enum(['message', 'success', 'error'])
+const ToastSchema = z.object({
+ description: z.string(),
+ id: z.string().default(() => cuid()),
+ title: z.string().optional(),
+ type: TypeSchema.default('message'),
+})
+
+export type Toast = z.infer<typeof ToastSchema>
+export type OptionalToast = Omit<Toast, 'id' | 'type'> & {
+ id?: string
+ type?: z.infer<typeof TypeSchema>
+}
+
+const toastSessionStorage = createCookieSessionStorage({
+ cookie: {
+ name: 'en_toast',
+ sameSite: 'lax',
+ path: '/',
+ httpOnly: true,
+ secrets: process.env.SESSION_SECRET.split(','),
+ secure: process.env.NODE_ENV === 'production',
+ },
+})
+
+export async function redirectWithToast(
+ url: string,
+ toast: OptionalToast,
+ init?: ResponseInit,
+) {
+ return redirect(url, {
+ ...init,
+ headers: combineHeaders(init?.headers, await createToastHeaders(toast)),
+ })
+}
+
+export async function createToastHeaders(optionalToast: OptionalToast) {
+ const session = await toastSessionStorage.getSession()
+ const toast = ToastSchema.parse(optionalToast)
+ // using session.flash so next time .get is called, it's immediately removed
+ session.flash(toastKey, toast)
+ const cookie = await toastSessionStorage.commitSession(session)
+ return new Headers({ 'set-cookie': cookie })
+}
+
+export async function getToast(request: Request) {
+ const session = await toastSessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const result = ToastSchema.safeParse(session.get(toastKey))
+ const toast = result.success ? result.data : null
+ const headers = new Headers({
+ 'set-cookie': await toastSessionStorage.commitSession(session),
+ })
+ return { toast, headers }
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/verification.server.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/verification.server.ts
new file mode 100644
index 0000000..563b1c4
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/verification.server.ts
@@ -0,0 +1,13 @@
+import { createCookieSessionStorage } from '@remix-run/node'
+
+export const verifySessionStorage = createCookieSessionStorage({
+ cookie: {
+ name: 'en_verification',
+ sameSite: 'lax',
+ path: '/',
+ httpOnly: true,
+ maxAge: 60 * 10, // 10 minutes
+ secrets: process.env.SESSION_SECRET.split(','),
+ secure: process.env.NODE_ENV === 'production',
+ },
+})
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/index.js var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/index.js
index a4027ae..1dde10a 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/index.js
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/index.js
@@ -1,7 +1,21 @@
+import 'dotenv/config'
+import closeWithGrace from 'close-with-grace'
+import chalk from 'chalk'
+
+closeWithGrace(async ({ err }) => {
+ if (err) {
+ console.error(chalk.red(err))
+ console.error(chalk.red(err.stack))
+ process.exit(1)
+ }
+})
+
+if (process.env.MOCKS === 'true') {
+ await import('./tests/mocks/index.ts')
+}
+
if (process.env.NODE_ENV === 'production') {
await import('./server-build/index.js')
} else {
await import('./server/index.ts')
}
-
-// wat
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/other/build-icons.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/build-icons.ts
index 3c84ead..857154f 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/other/build-icons.ts
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/build-icons.ts
@@ -44,15 +44,13 @@ async function generateIconFiles() {
)
if (spriteUpToDate && typesUpToDate) {
- console.log(`Icons are up to date`)
+ logVerbose(`Icons are up to date`)
return
}
logVerbose(`Generating sprite for ${inputDirRelative}`)
- await fsExtra.emptyDir(outputDir)
-
- await generateSvgSprite({
+ const spriteChanged = await generateSvgSprite({
files,
inputDir,
outputPath: spriteFilepath,
@@ -70,11 +68,14 @@ async function generateIconFiles() {
export type IconName =
\t| ${stringifiedIconNames.join('\n\t| ')};
`
- await writeIfChanged(typeOutputFilepath, typeOutputContent)
+ const typesChanged = await writeIfChanged(
+ typeOutputFilepath,
+ typeOutputContent,
+ )
logVerbose(`Manifest saved to ${path.relative(cwd, typeOutputFilepath)}`)
- await writeIfChanged(
+ const readmeChanged = await writeIfChanged(
path.join(outputDir, 'README.md'),
`# Icons
@@ -83,7 +84,10 @@ This directory contains SVG icons that are used by the app.
Everything in this directory is generated by \`npm run build:icons\`.
`,
)
- console.log(`Generated ${files.length} icons`)
+
+ if (spriteChanged || typesChanged || readmeChanged) {
+ console.log(`Generated ${files.length} icons`)
+ }
}
function iconName(file: string) {
@@ -141,6 +145,7 @@ async function writeIfChanged(filepath: string, newContent: string) {
const currentContent = await fsExtra
.readFile(filepath, 'utf8')
.catch(() => '')
- if (currentContent === newContent) return
+ if (currentContent === newContent) return false
await fsExtra.writeFile(filepath, newContent, 'utf8')
+ return true
}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/github-logo.svg var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/github-logo.svg
new file mode 100644
index 0000000..d2d53ac
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/github-logo.svg
@@ -0,0 +1,11 @@
+<!-- Downloaded from @radix-ui/icons -->
+<!-- License https://github.com/radix-ui/icons/blob/master/LICENSE -->
+<!-- https://github.com/radix-ui/icons/blob/master/packages/radix-icons/icons/github-log.svg -->
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M7.49933 0.25C3.49635 0.25 0.25 3.49593 0.25 7.50024C0.25 10.703 2.32715 13.4206 5.2081 14.3797C5.57084 14.446 5.70302 14.2222 5.70302 14.0299C5.70302 13.8576 5.69679 13.4019 5.69323 12.797C3.67661 13.235 3.25112 11.825 3.25112 11.825C2.92132 10.9874 2.44599 10.7644 2.44599 10.7644C1.78773 10.3149 2.49584 10.3238 2.49584 10.3238C3.22353 10.375 3.60629 11.0711 3.60629 11.0711C4.25298 12.1788 5.30335 11.8588 5.71638 11.6732C5.78225 11.205 5.96962 10.8854 6.17658 10.7043C4.56675 10.5209 2.87415 9.89918 2.87415 7.12104C2.87415 6.32925 3.15677 5.68257 3.62053 5.17563C3.54576 4.99226 3.29697 4.25521 3.69174 3.25691C3.69174 3.25691 4.30015 3.06196 5.68522 3.99973C6.26337 3.83906 6.8838 3.75895 7.50022 3.75583C8.1162 3.75895 8.73619 3.83906 9.31523 3.99973C10.6994 3.06196 11.3069 3.25691 11.3069 3.25691C11.7026 4.25521 11.4538 4.99226 11.3795 5.17563C11.8441 5.68257 12.1245 6.32925 12.1245 7.12104C12.1245 9.9063 10.4292 10.5192 8.81452 10.6985C9.07444 10.9224 9.30633 11.3648 9.30633 12.0413C9.30633 13.0102 9.29742 13.7922 9.29742 14.0299C9.29742 14.2239 9.42828 14.4496 9.79591 14.3788C12.6746 13.4179 14.75 10.7025 14.75 7.50024C14.75 3.49593 11.5036 0.25 7.49933 0.25Z"
+ fill="currentColor"
+ />
+</svg>
\ No newline at end of file
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/link-2.svg var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/link-2.svg
new file mode 100644
index 0000000..a4924ff
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/link-2.svg
@@ -0,0 +1,11 @@
+<!-- Downloaded from @radix-ui/icons -->
+<!-- License https://github.com/radix-ui/icons/blob/master/LICENSE -->
+<!-- https://github.com/radix-ui/icons/blob/master/packages/radix-icons/icons/link-2.svg -->
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M8.51194 3.00541C9.18829 2.54594 10.0435 2.53694 10.6788 2.95419C10.8231 3.04893 10.9771 3.1993 11.389 3.61119C11.8009 4.02307 11.9513 4.17714 12.046 4.32141C12.4633 4.95675 12.4543 5.81192 11.9948 6.48827C11.8899 6.64264 11.7276 6.80811 11.3006 7.23511L10.6819 7.85383C10.4867 8.04909 10.4867 8.36567 10.6819 8.56093C10.8772 8.7562 11.1938 8.7562 11.389 8.56093L12.0077 7.94221L12.0507 7.89929C12.4203 7.52976 12.6568 7.2933 12.822 7.0502C13.4972 6.05623 13.5321 4.76252 12.8819 3.77248C12.7233 3.53102 12.4922 3.30001 12.1408 2.94871L12.0961 2.90408L12.0515 2.85942C11.7002 2.508 11.4692 2.27689 11.2277 2.11832C10.2377 1.46813 8.94398 1.50299 7.95001 2.17822C7.70691 2.34336 7.47044 2.57991 7.1009 2.94955L7.058 2.99247L6.43928 3.61119C6.24401 3.80645 6.24401 4.12303 6.43928 4.31829C6.63454 4.51355 6.95112 4.51355 7.14638 4.31829L7.7651 3.69957C8.1921 3.27257 8.35757 3.11027 8.51194 3.00541ZM4.31796 7.14672C4.51322 6.95146 4.51322 6.63487 4.31796 6.43961C4.12269 6.24435 3.80611 6.24435 3.61085 6.43961L2.99213 7.05833L2.94922 7.10124C2.57957 7.47077 2.34303 7.70724 2.17788 7.95035C1.50265 8.94432 1.4678 10.238 2.11799 11.2281C2.27656 11.4695 2.50766 11.7005 2.8591 12.0518L2.90374 12.0965L2.94837 12.1411C3.29967 12.4925 3.53068 12.7237 3.77214 12.8822C4.76219 13.5324 6.05589 13.4976 7.04986 12.8223C7.29296 12.6572 7.52943 12.4206 7.89896 12.051L7.89897 12.051L7.94188 12.0081L8.5606 11.3894C8.75586 11.1941 8.75586 10.8775 8.5606 10.6823C8.36533 10.487 8.04875 10.487 7.85349 10.6823L7.23477 11.301C6.80777 11.728 6.6423 11.8903 6.48794 11.9951C5.81158 12.4546 4.95642 12.4636 4.32107 12.0464C4.17681 11.9516 4.02274 11.8012 3.61085 11.3894C3.19896 10.9775 3.0486 10.8234 2.95385 10.6791C2.53661 10.0438 2.54561 9.18863 3.00507 8.51227C3.10993 8.35791 3.27224 8.19244 3.69924 7.76544L4.31796 7.14672ZM9.62172 6.08558C9.81698 5.89032 9.81698 5.57373 9.62172 5.37847C9.42646 5.18321 9.10988 5.18321 8.91461 5.37847L5.37908 8.91401C5.18382 9.10927 5.18382 9.42585 5.37908 9.62111C5.57434 9.81637 5.89092 9.81637 6.08619 9.62111L9.62172 6.08558Z"
+ fill="currentColor"
+ />
+</svg>
\ No newline at end of file
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/question-mark-circled.svg var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/question-mark-circled.svg
new file mode 100644
index 0000000..70a8572
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/question-mark-circled.svg
@@ -0,0 +1,11 @@
+<!-- Downloaded from @radix-ui/icons -->
+<!-- License https://github.com/radix-ui/icons/blob/master/LICENSE -->
+<!-- https://github.com/radix-ui/icons/blob/master/packages/radix-icons/icons/question-mark-circled.svg -->
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M0.877075 7.49972C0.877075 3.84204 3.84222 0.876892 7.49991 0.876892C11.1576 0.876892 14.1227 3.84204 14.1227 7.49972C14.1227 11.1574 11.1576 14.1226 7.49991 14.1226C3.84222 14.1226 0.877075 11.1574 0.877075 7.49972ZM7.49991 1.82689C4.36689 1.82689 1.82708 4.36671 1.82708 7.49972C1.82708 10.6327 4.36689 13.1726 7.49991 13.1726C10.6329 13.1726 13.1727 10.6327 13.1727 7.49972C13.1727 4.36671 10.6329 1.82689 7.49991 1.82689ZM8.24993 10.5C8.24993 10.9142 7.91414 11.25 7.49993 11.25C7.08571 11.25 6.74993 10.9142 6.74993 10.5C6.74993 10.0858 7.08571 9.75 7.49993 9.75C7.91414 9.75 8.24993 10.0858 8.24993 10.5ZM6.05003 6.25C6.05003 5.57211 6.63511 4.925 7.50003 4.925C8.36496 4.925 8.95003 5.57211 8.95003 6.25C8.95003 6.74118 8.68002 6.99212 8.21447 7.27494C8.16251 7.30651 8.10258 7.34131 8.03847 7.37854L8.03841 7.37858C7.85521 7.48497 7.63788 7.61119 7.47449 7.73849C7.23214 7.92732 6.95003 8.23198 6.95003 8.7C6.95004 9.00376 7.19628 9.25 7.50004 9.25C7.8024 9.25 8.04778 9.00601 8.05002 8.70417L8.05056 8.7033C8.05924 8.6896 8.08493 8.65735 8.15058 8.6062C8.25207 8.52712 8.36508 8.46163 8.51567 8.37436L8.51571 8.37433C8.59422 8.32883 8.68296 8.27741 8.78559 8.21506C9.32004 7.89038 10.05 7.35382 10.05 6.25C10.05 4.92789 8.93511 3.825 7.50003 3.825C6.06496 3.825 4.95003 4.92789 4.95003 6.25C4.95003 6.55376 5.19628 6.8 5.50003 6.8C5.80379 6.8 6.05003 6.55376 6.05003 6.25Z"
+ fill="currentColor"
+ />
+</svg>
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/package.json var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/package.json
index f764847..fff76db 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/package.json
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/package.json
@@ -1,5 +1,5 @@
{
- "name": "exercises__sep__11.finish__sep__01.solution.finished",
+ "name": "exercises__sep__11.finish__sep__01.problem.finished",
"private": true,
"sideEffects": false,
"type": "module",
@@ -18,6 +18,7 @@
"dependencies": {
"@conform-to/react": "^0.7.3",
"@conform-to/zod": "^0.7.3",
+ "@epic-web/totp": "1.0.4",
"@kentcdodds/workshop-app": "^2.10.5",
"@mswjs/data": "^0.13.0",
"@paralleldrive/cuid2": "^2.2.1",
@@ -28,6 +29,7 @@
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.6",
+ "@react-email/components": "^0.0.7",
"@remix-run/css-bundle": "^1.19.0",
"@remix-run/node": "^1.19.0",
"@remix-run/react": "^1.19.0",
@@ -48,14 +50,18 @@
"isbot": "^3.6.13",
"morgan": "^1.10.0",
"prisma": "^5.0.0",
+ "qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix-auth": "^3.5.0",
"remix-auth-form": "^1.3.0",
+ "remix-auth-github": "^1.5.0",
"remix-utils": "^6.6.0",
+ "sonner": "^0.6.0",
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6",
+ "thirty-two": "^1.0.2",
"zod": "^3.21.4"
},
"devDependencies": {
@@ -75,6 +81,8 @@
"@types/express": "^4.17.17",
"@types/fs-extra": "^11.0.1",
"@types/morgan": "^1.9.4",
+ "@types/node": "^20.4.1",
+ "@types/qrcode": "^1.5.1",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.4",
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/prisma/migrations/20230801024443_init/migration.sql var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/prisma/migrations/20230804054702_init/migration.sql
similarity index 80%
rename from /var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/prisma/migrations/20230801024443_init/migration.sql
rename to /var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/prisma/migrations/20230804054702_init/migration.sql
index b71af16..3674858 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/prisma/migrations/20230801024443_init/migration.sql
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/prisma/migrations/20230804054702_init/migration.sql
@@ -80,6 +80,29 @@ CREATE TABLE "Role" (
"updatedAt" DATETIME NOT NULL
);
+-- 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,
+ "expiresAt" DATETIME
+);
+
+-- CreateTable
+CREATE TABLE "GitHubConnection" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "providerId" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ "userId" TEXT NOT NULL,
+ CONSTRAINT "GitHubConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
-- CreateTable
CREATE TABLE "_PermissionToRole" (
"A" TEXT NOT NULL,
@@ -126,6 +149,15 @@ CREATE UNIQUE INDEX "Permission_action_entity_access_key" ON "Permission"("actio
-- CreateIndex
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
+-- CreateIndex
+CREATE UNIQUE INDEX "Verification_target_type_key" ON "Verification"("target", "type");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "GitHubConnection_providerId_key" ON "GitHubConnection"("providerId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "GitHubConnection_providerId_userId_key" ON "GitHubConnection"("providerId", "userId");
+
-- CreateIndex
CREATE UNIQUE INDEX "_PermissionToRole_AB_unique" ON "_PermissionToRole"("A", "B");
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/prisma/schema.prisma var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/prisma/schema.prisma
index 469cc58..360c675 100644
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/prisma/schema.prisma
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/prisma/schema.prisma
@@ -19,11 +19,12 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- image UserImage?
- password Password?
- notes Note[]
- roles Role[]
- sessions Session[]
+ image UserImage?
+ password Password?
+ notes Note[]
+ roles Role[]
+ sessions Session[]
+ gitHubConnections GitHubConnection[]
}
model Note {
@@ -121,3 +122,44 @@ model Role {
users User[]
permissions Permission[]
}
+
+model Verification {
+ id String @id @default(cuid())
+ createdAt DateTime @default(now())
+
+ /// The type of verification, e.g. "email" or "phone"
+ type String
+
+ /// The thing we're trying to verify, e.g. a user's email or phone number
+ target String
+
+ /// The secret key used to generate the otp
+ secret String
+
+ /// The algorithm used to generate the otp
+ algorithm String
+
+ /// The number of digits in the otp
+ digits Int
+
+ /// The number of seconds the otp is valid for
+ period Int
+
+ /// When it's safe to delete this verification
+ expiresAt DateTime?
+
+ @@unique([target, type])
+}
+
+model GitHubConnection {
+ id String @id @default(cuid())
+ providerId String @unique
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ userId String
+
+ @@unique([providerId, userId])
+}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/tests/fixtures/images/ghost.jpg var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/tests/fixtures/images/ghost.jpg
new file mode 100644
index 0000000..e5c8bd5
Binary files /dev/null and var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/tests/fixtures/images/ghost.jpg differ
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/tests/mocks/index.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/tests/mocks/index.ts
new file mode 100644
index 0000000..891154a
--- /dev/null
+++ var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/tests/mocks/index.ts
@@ -0,0 +1,107 @@
+import fs from 'node:fs'
+import { rest } from 'msw'
+import { setupServer } from 'msw/node'
+import closeWithGrace from 'close-with-grace'
+import { faker } from '@faker-js/faker'
+
+const MOCK_GITHUB_ID = 123456789
+const MOCK_ACCESS_TOKEN = '__MOCK_ACCESS_TOKEN__'
+const handlers = [
+ process.env.REMIX_DEV_HTTP_ORIGIN
+ ? rest.post(`${process.env.REMIX_DEV_HTTP_ORIGIN}ping`, req =>
+ req.passthrough(),
+ )
+ : null,
+
+ rest.post(`https://api.resend.com/emails`, async (req, res, ctx) => {
+ const body = await req.json()
+ console.info('🔶 mocked email contents:', body)
+
+ return res(
+ ctx.json({
+ id: faker.string.uuid(),
+ from: body.from,
+ to: body.to,
+ created_at: new Date().toISOString(),
+ }),
+ )
+ }),
+
+ // test this github stuff out without going through github's oauth flow by
+ // going to http://localhost:3000/auth/github/callback?code=MOCK_CODE&state=MOCK_STATE
+ rest.post(
+ 'https://github.com/login/oauth/access_token',
+ async (req, res, ctx) => {
+ const params = new URLSearchParams(await req.text())
+ if (params.get('code') !== 'MOCK_CODE') {
+ return req.passthrough()
+ }
+
+ return res(
+ ctx.body(
+ new URLSearchParams({
+ access_token: MOCK_ACCESS_TOKEN,
+ token_type: '__MOCK_TOKEN_TYPE__',
+ }).toString(),
+ ),
+ )
+ },
+ ),
+ rest.get('https://api.github.com/user/emails', async (req, res, ctx) => {
+ if (!req.headers.get('authorization')?.includes(MOCK_ACCESS_TOKEN)) {
+ return req.passthrough()
+ }
+
+ return res(ctx.json([{ email: 'mock@example.com' }]))
+ }),
+ rest.get('https://api.github.com/user/:id', async (req, res, ctx) => {
+ if (
+ req.params.id !== String(MOCK_GITHUB_ID) &&
+ !req.headers.get('authorization')?.includes('MOCK')
+ ) {
+ return req.passthrough()
+ }
+
+ return res(
+ ctx.json({
+ login: 'mocked-login',
+ id: MOCK_GITHUB_ID,
+ name: 'Mocked User',
+ avatar_url: 'https://github.com/ghost.png',
+ emails: ['mock@example.com'],
+ }),
+ )
+ }),
+ rest.get('https://api.github.com/user', async (req, res, ctx) => {
+ if (!req.headers.get('authorization')?.includes(MOCK_ACCESS_TOKEN)) {
+ return req.passthrough()
+ }
+
+ return res(
+ ctx.json({
+ login: 'mocked-login',
+ id: MOCK_GITHUB_ID,
+ name: 'Mocked User',
+ avatar_url: 'https://github.com/ghost.png',
+ emails: ['mock@example.com'],
+ }),
+ )
+ }),
+ // the .png is not a mistake even though it looks like it... It's really a jpg
+ // but the ghost image URL really has a png extension 😅
+ rest.get('https://github.com/ghost.png', async (req, res, ctx) => {
+ const buffer = await fs.promises.readFile(
+ './tests/fixtures/images/ghost.jpg',
+ )
+ return res(ctx.body(buffer))
+ }),
+].filter(Boolean)
+
+const server = setupServer(...handlers)
+
+server.listen({ onUnhandledRequest: 'warn' })
+console.info('🔶 Mock server installed')
+
+closeWithGrace(() => {
+ server.close()
+})
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/playwright-utils.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/playwright-utils.ts
deleted file mode 100644
index be9d532..0000000
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/playwright-utils.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { test as base, type Page } from '@playwright/test'
-import { parse } from 'cookie'
-import { getPasswordHash, sessionKey } from '~/utils/auth.server.ts'
-import { prisma } from '~/utils/db.server.ts'
-import { commitSession, getSession } from '~/utils/session.server.ts'
-import { createUser } from '../tests/db-utils.ts'
-
-export const dataCleanup = {
- users: new Set<string>(),
-}
-
-export function deleteUserByUsername(username: string) {
- return prisma.user.delete({ where: { username } })
-}
-
-export async function insertNewUser({
- username,
- password,
-}: { username?: string; password?: string } = {}) {
- const userData = createUser()
- username = username ?? userData.username
- const user = (await prisma.user
- .create({
- data: {
- ...userData,
- username,
- password: {
- create: {
- hash: await getPasswordHash(password || userData.username),
- },
- },
- },
- select: { id: true, name: true, username: true, email: true },
- })
- .catch(async err => {
- // sometimes the tests fail before data cleanup can happen. So we'll just
- // delete the user and try again.
- if (
- err instanceof Error &&
- err.message.includes(
- 'Unique constraint failed on the fields: (`username`)',
- )
- ) {
- await prisma.user.delete({ where: { username } })
- return insertNewUser({ username, password })
- } else {
- throw err
- }
- })) as { id: string; name: string; username: string; email: string }
- dataCleanup.users.add(user.id)
- return user
-}
-
-export const test = base.extend<{
- login: (user?: { id: string }) => ReturnType<typeof loginPage>
-}>({
- login: [
- async ({ page, baseURL }, use) => {
- await use(user => loginPage({ page, baseURL, user }))
- },
- { auto: true },
- ],
-})
-
-export const { expect } = test
-
-export async function loginPage({
- page,
- baseURL = `http://localhost:${process.env.PORT}/`,
- user: givenUser,
-}: {
- page: Page
- baseURL: string | undefined
- user?: { id: string }
-}) {
- const user = givenUser
- ? await prisma.user.findUniqueOrThrow({
- where: { id: givenUser.id },
- select: {
- id: true,
- email: true,
- username: true,
- name: true,
- },
- })
- : await insertNewUser()
- const session = await prisma.session.create({
- data: {
- expirationDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
- userId: user.id,
- },
- select: { id: true },
- })
-
- const cookieSession = await getSession()
- cookieSession.set(sessionKey, session.id)
- const cookieValue = await commitSession(cookieSession)
- const { _session } = parse(cookieValue)
- await page.context().addCookies([
- {
- name: '_session',
- sameSite: 'Lax',
- url: baseURL,
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- value: _session,
- },
- ])
- return user
-}
-
-/**
- * This allows you to wait for something (like an email to be available).
- *
- * It calls the callback every 50ms until it returns a value (and does not throw
- * an error). After the timeout, it will throw the last error that was thrown or
- * throw the error message provided as a fallback
- */
-export async function waitFor<ReturnValue>(
- cb: () => ReturnValue | Promise<ReturnValue>,
- {
- errorMessage,
- timeout = 5000,
- }: { errorMessage?: string; timeout?: number } = {},
-) {
- const endTime = Date.now() + timeout
- let lastError: unknown = new Error(errorMessage)
- while (Date.now() < endTime) {
- try {
- const response = await cb()
- if (response) return response
- } catch (e: unknown) {
- lastError = e
- }
- await new Promise(r => setTimeout(r, 100))
- }
- throw lastError
-}
-
-test.afterEach(async () => {
- type Delegate = {
- deleteMany: (opts: {
- where: { id: { in: Array<string> } }
- }) => Promise<unknown>
- }
- async function deleteAll(items: Set<string>, delegate: Delegate) {
- if (items.size > 0) {
- await delegate.deleteMany({
- where: { id: { in: [...items] } },
- })
- }
- }
- await deleteAll(dataCleanup.users, prisma.user)
-})
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/global-setup.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/global-setup.ts
deleted file mode 100644
index c02b7ef..0000000
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/global-setup.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import path from 'path'
-import { execaCommand } from 'execa'
-import fsExtra from 'fs-extra'
-import { BASE_DATABASE_PATH, BASE_DATABASE_URL } from './paths.ts'
-
-export async function setup() {
- await fsExtra.ensureDir(path.dirname(BASE_DATABASE_PATH))
- await ensureDbReady()
- return async function teardown() {}
-}
-
-async function ensureDbReady() {
- if (!(await fsExtra.pathExists(BASE_DATABASE_PATH))) {
- await execaCommand(
- 'prisma migrate reset --force --skip-seed --skip-generate',
- {
- stdio: 'inherit',
- env: {
- ...process.env,
- DATABASE_PATH: BASE_DATABASE_PATH,
- DATABASE_URL: BASE_DATABASE_URL,
- },
- },
- )
- }
-}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/matchers.cjs var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/matchers.cjs
deleted file mode 100644
index aa7f43f..0000000
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/matchers.cjs
+++ /dev/null
@@ -1,5 +0,0 @@
-// matchers types are missing when import as default to ESM module
-export {
- default as matchers,
- TestingLibraryMatchers,
-} from '@testing-library/jest-dom/matchers.js'
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/paths.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/paths.ts
deleted file mode 100644
index 18f5171..0000000
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/paths.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import path from 'path'
-
-const databaseFile = `./tests/prisma/data.${process.env.VITEST_POOL_ID || 0}.db`
-export const DATABASE_PATH = path.join(process.cwd(), databaseFile)
-export const DATABASE_URL = `file:${DATABASE_PATH}?connection_limit=1`
-
-const baseDatabaseFile = `./tests/prisma/base.db`
-export const BASE_DATABASE_PATH = path.join(process.cwd(), baseDatabaseFile)
-export const BASE_DATABASE_URL = `file:${BASE_DATABASE_PATH}?connection_limit=1`
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/setup-env-vars.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/setup-env-vars.ts
deleted file mode 100644
index 681bf76..0000000
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/setup-env-vars.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { DATABASE_PATH, DATABASE_URL } from './paths.ts'
-
-process.env.DATABASE_PATH = DATABASE_PATH
-process.env.DATABASE_URL = DATABASE_URL
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/setup-test-env.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/setup-test-env.ts
deleted file mode 100644
index 274d763..0000000
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/setup-test-env.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import './setup-env-vars.ts'
-import { afterAll, afterEach } from 'vitest'
-import { installGlobals } from '@remix-run/node'
-import 'dotenv/config'
-import fs from 'fs'
-import { BASE_DATABASE_PATH, DATABASE_PATH } from './paths.ts'
-import { prisma } from '~/utils/db.server.ts'
-
-installGlobals()
-fs.copyFileSync(BASE_DATABASE_PATH, DATABASE_PATH)
-
-afterEach(async () => {
- await prisma.user.deleteMany()
- await prisma.permission.deleteMany()
- await prisma.role.deleteMany()
-})
-
-afterAll(async () => {
- await prisma.$disconnect()
- await fs.promises.rm(DATABASE_PATH)
-})
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/vitejs-plugin-react.cjs var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/vitejs-plugin-react.cjs
deleted file mode 100644
index ff66262..0000000
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/setup/vitejs-plugin-react.cjs
+++ /dev/null
@@ -1,2 +0,0 @@
-// react types are missing when import as default to ESM module
-export { default as react } from '@vitejs/plugin-react'
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/vitest-utils.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/vitest-utils.ts
deleted file mode 100644
index aeed9ac..0000000
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/tests/vitest-utils.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { sessionKey } from '~/utils/auth.server.ts'
-import { commitSession, getSession } from '~/utils/session.server.ts'
-
-export const BASE_URL = 'https://epicstack.dev'
-
-export async function getSessionSetCookieHeader(
- session: { id: string },
- existingCookie?: string,
-) {
- const cookieSession = await getSession(existingCookie)
- cookieSession.set(sessionKey, session.id)
- const setCookieHeader = await commitSession(cookieSession)
- return setCookieHeader
-}
diff --git var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/vitest.config.ts var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/vitest.config.ts
deleted file mode 100644
index 5947458..0000000
--- var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/vitest.config.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/// <reference types="vitest" />
-
-import { react } from './tests/setup/vitejs-plugin-react.cjs'
-import { defineConfig } from 'vite'
-import tsconfigPaths from 'vite-tsconfig-paths'
-
-export default defineConfig({
- plugins: [react(), tsconfigPaths()],
- css: { postcss: { plugins: [] } },
- test: {
- include: ['./app/**/*.test.{ts,tsx}'],
- environment: 'jsdom',
- setupFiles: ['./tests/setup/setup-test-env.ts'],
- globalSetup: ['./tests/setup/global-setup.ts'],
- coverage: {
- include: ['app/**/*.{ts,tsx}'],
- all: true,
- },
- },
-})
{
"type": "GitDiff",
"files": [
{
"type": "ChangedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 6
},
"fromFileRange": {
"start": 1,
"lines": 2
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 1,
"lineAfter": 1,
"content": "DATABASE_URL=\"file:./data.db\""
},
{
"type": "UnchangedLine",
"lineBefore": 2,
"lineAfter": 2,
"content": "SESSION_SECRET=\"super-duper-s3cret\""
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "RESEND_API_KEY=\"some-secret-key\""
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "GITHUB_TOKEN=\"MOCK_abc12392lfkjlsf0\""
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "GITHUB_CLIENT_ID=\"MOCK_Iv1.abc12392lfkjlsf0\""
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "GITHUB_CLIENT_SECRET=\"MOCK_super-duper-s3cret-thing\""
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/.env"
},
{
"type": "ChangedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 31
},
"fromFileRange": {
"start": 1,
"lines": 31
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 1,
"lineAfter": 1,
"content": "// This file is generated by npm run build:icons"
},
{
"type": "UnchangedLine",
"lineBefore": 2,
"lineAfter": 2,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 3,
"lineAfter": 3,
"content": "export type IconName ="
},
{
"type": "DeletedLine",
"lineBefore": 4,
"content": "\t| 'arrow-left'"
},
{
"type": "DeletedLine",
"lineBefore": 5,
"content": "\t| 'arrow-right'"
},
{
"type": "DeletedLine",
"lineBefore": 6,
"content": "\t| 'avatar'"
},
{
"type": "DeletedLine",
"lineBefore": 7,
"content": "\t| 'backpack'"
},
{
"type": "DeletedLine",
"lineBefore": 8,
"content": "\t| 'camera'"
},
{
"type": "DeletedLine",
"lineBefore": 9,
"content": "\t| 'check'"
},
{
"type": "DeletedLine",
"lineBefore": 10,
"content": "\t| 'clock'"
},
{
"type": "DeletedLine",
"lineBefore": 11,
"content": "\t| 'cross-1'"
},
{
"type": "DeletedLine",
"lineBefore": 12,
"content": "\t| 'dots-horizontal'"
},
{
"type": "DeletedLine",
"lineBefore": 13,
"content": "\t| 'download'"
},
{
"type": "DeletedLine",
"lineBefore": 14,
"content": "\t| 'envelope-closed'"
},
{
"type": "DeletedLine",
"lineBefore": 15,
"content": "\t| 'exit'"
},
{
"type": "DeletedLine",
"lineBefore": 16,
"content": "\t| 'file-text'"
},
{
"type": "DeletedLine",
"lineBefore": 17,
"content": "\t| 'github-logo'"
},
{
"type": "DeletedLine",
"lineBefore": 18,
"content": "\t| 'laptop'"
},
{
"type": "DeletedLine",
"lineBefore": 19,
"content": "\t| 'link-2'"
},
{
"type": "DeletedLine",
"lineBefore": 20,
"content": "\t| 'lock-closed'"
},
{
"type": "DeletedLine",
"lineBefore": 21,
"content": "\t| 'lock-open-1'"
},
{
"type": "DeletedLine",
"lineBefore": 22,
"content": "\t| 'magnifying-glass'"
},
{
"type": "DeletedLine",
"lineBefore": 23,
"content": "\t| 'moon'"
},
{
"type": "DeletedLine",
"lineBefore": 24,
"content": "\t| 'pencil-1'"
},
{
"type": "DeletedLine",
"lineBefore": 25,
"content": "\t| 'pencil-2'"
},
{
"type": "DeletedLine",
"lineBefore": 26,
"content": "\t| 'plus'"
},
{
"type": "DeletedLine",
"lineBefore": 27,
"content": "\t| 'question-mark-circled'"
},
{
"type": "DeletedLine",
"lineBefore": 28,
"content": "\t| 'reset'"
},
{
"type": "DeletedLine",
"lineBefore": 29,
"content": "\t| 'sun'"
},
{
"type": "DeletedLine",
"lineBefore": 30,
"content": "\t| 'trash'"
},
{
"type": "DeletedLine",
"lineBefore": 31,
"content": "\t| 'update'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "\t| \"arrow-left\""
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "\t| \"arrow-right\""
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\t| \"avatar\""
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\t| \"backpack\""
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "\t| \"camera\""
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "\t| \"check\""
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "\t| \"clock\""
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "\t| \"cross-1\""
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\t| \"dots-horizontal\""
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\t| \"download\""
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "\t| \"envelope-closed\""
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "\t| \"exit\""
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "\t| \"file-text\""
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "\t| \"github-logo\""
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "\t| \"laptop\""
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "\t| \"link-2\""
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "\t| \"lock-closed\""
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "\t| \"lock-open-1\""
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "\t| \"magnifying-glass\""
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "\t| \"moon\""
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "\t| \"pencil-1\""
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "\t| \"pencil-2\""
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\t| \"plus\""
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "\t| \"question-mark-circled\""
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\t| \"reset\""
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\t| \"sun\""
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\t| \"trash\""
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\t| \"update\";"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/components/ui/icons/name.d.ts"
},
{
"type": "ChangedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 93,
"lines": 13
},
"fromFileRange": {
"start": 93,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 93,
"lineAfter": 93,
"content": " fill=\"currentColor\""
},
{
"type": "UnchangedLine",
"lineBefore": 94,
"lineAfter": 94,
"content": " ></path>"
},
{
"type": "UnchangedLine",
"lineBefore": 95,
"lineAfter": 95,
"content": "</symbol>"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "<symbol viewBox=\"0 0 15 15\" fill=\"none\" id=\"github-logo\">"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": " <path fill-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": " clip-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": " d=\"M7.49933 0.25C3.49635 0.25 0.25 3.49593 0.25 7.50024C0.25 10.703 2.32715 13.4206 5.2081 14.3797C5.57084 14.446 5.70302 14.2222 5.70302 14.0299C5.70302 13.8576 5.69679 13.4019 5.69323 12.797C3.67661 13.235 3.25112 11.825 3.25112 11.825C2.92132 10.9874 2.44599 10.7644 2.44599 10.7644C1.78773 10.3149 2.49584 10.3238 2.49584 10.3238C3.22353 10.375 3.60629 11.0711 3.60629 11.0711C4.25298 12.1788 5.30335 11.8588 5.71638 11.6732C5.78225 11.205 5.96962 10.8854 6.17658 10.7043C4.56675 10.5209 2.87415 9.89918 2.87415 7.12104C2.87415 6.32925 3.15677 5.68257 3.62053 5.17563C3.54576 4.99226 3.29697 4.25521 3.69174 3.25691C3.69174 3.25691 4.30015 3.06196 5.68522 3.99973C6.26337 3.83906 6.8838 3.75895 7.50022 3.75583C8.1162 3.75895 8.73619 3.83906 9.31523 3.99973C10.6994 3.06196 11.3069 3.25691 11.3069 3.25691C11.7026 4.25521 11.4538 4.99226 11.3795 5.17563C11.8441 5.68257 12.1245 6.32925 12.1245 7.12104C12.1245 9.9063 10.4292 10.5192 8.81452 10.6985C9.07444 10.9224 9.30633 11.3648 9.30633 12.0413C9.30633 13.0102 9.29742 13.7922 9.29742 14.0299C9.29742 14.2239 9.42828 14.4496 9.79591 14.3788C12.6746 13.4179 14.75 10.7025 14.75 7.50024C14.75 3.49593 11.5036 0.25 7.49933 0.25Z\""
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": " fill=\"currentColor\""
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": " ></path>"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "</symbol>"
},
{
"type": "UnchangedLine",
"lineBefore": 96,
"lineAfter": 103,
"content": "<symbol viewBox=\"0 0 15 15\" fill=\"none\" id=\"laptop\">"
},
{
"type": "UnchangedLine",
"lineBefore": 97,
"lineAfter": 104,
"content": " <path fill-rule=\"evenodd\""
},
{
"type": "UnchangedLine",
"lineBefore": 98,
"lineAfter": 105,
"content": " clip-rule=\"evenodd\""
}
]
},
{
"type": "Chunk",
"toFileRange": {
"start": 107,
"lines": 13
},
"fromFileRange": {
"start": 100,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 100,
"lineAfter": 107,
"content": " fill=\"currentColor\""
},
{
"type": "UnchangedLine",
"lineBefore": 101,
"lineAfter": 108,
"content": " ></path>"
},
{
"type": "UnchangedLine",
"lineBefore": 102,
"lineAfter": 109,
"content": "</symbol>"
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "<symbol viewBox=\"0 0 15 15\" fill=\"none\" id=\"link-2\">"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": " <path fill-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": " clip-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": " d=\"M8.51194 3.00541C9.18829 2.54594 10.0435 2.53694 10.6788 2.95419C10.8231 3.04893 10.9771 3.1993 11.389 3.61119C11.8009 4.02307 11.9513 4.17714 12.046 4.32141C12.4633 4.95675 12.4543 5.81192 11.9948 6.48827C11.8899 6.64264 11.7276 6.80811 11.3006 7.23511L10.6819 7.85383C10.4867 8.04909 10.4867 8.36567 10.6819 8.56093C10.8772 8.7562 11.1938 8.7562 11.389 8.56093L12.0077 7.94221L12.0507 7.89929C12.4203 7.52976 12.6568 7.2933 12.822 7.0502C13.4972 6.05623 13.5321 4.76252 12.8819 3.77248C12.7233 3.53102 12.4922 3.30001 12.1408 2.94871L12.0961 2.90408L12.0515 2.85942C11.7002 2.508 11.4692 2.27689 11.2277 2.11832C10.2377 1.46813 8.94398 1.50299 7.95001 2.17822C7.70691 2.34336 7.47044 2.57991 7.1009 2.94955L7.058 2.99247L6.43928 3.61119C6.24401 3.80645 6.24401 4.12303 6.43928 4.31829C6.63454 4.51355 6.95112 4.51355 7.14638 4.31829L7.7651 3.69957C8.1921 3.27257 8.35757 3.11027 8.51194 3.00541ZM4.31796 7.14672C4.51322 6.95146 4.51322 6.63487 4.31796 6.43961C4.12269 6.24435 3.80611 6.24435 3.61085 6.43961L2.99213 7.05833L2.94922 7.10124C2.57957 7.47077 2.34303 7.70724 2.17788 7.95035C1.50265 8.94432 1.4678 10.238 2.11799 11.2281C2.27656 11.4695 2.50766 11.7005 2.8591 12.0518L2.90374 12.0965L2.94837 12.1411C3.29967 12.4925 3.53068 12.7237 3.77214 12.8822C4.76219 13.5324 6.05589 13.4976 7.04986 12.8223C7.29296 12.6572 7.52943 12.4206 7.89896 12.051L7.89897 12.051L7.94188 12.0081L8.5606 11.3894C8.75586 11.1941 8.75586 10.8775 8.5606 10.6823C8.36533 10.487 8.04875 10.487 7.85349 10.6823L7.23477 11.301C6.80777 11.728 6.6423 11.8903 6.48794 11.9951C5.81158 12.4546 4.95642 12.4636 4.32107 12.0464C4.17681 11.9516 4.02274 11.8012 3.61085 11.3894C3.19896 10.9775 3.0486 10.8234 2.95385 10.6791C2.53661 10.0438 2.54561 9.18863 3.00507 8.51227C3.10993 8.35791 3.27224 8.19244 3.69924 7.76544L4.31796 7.14672ZM9.62172 6.08558C9.81698 5.89032 9.81698 5.57373 9.62172 5.37847C9.42646 5.18321 9.10988 5.18321 8.91461 5.37847L5.37908 8.91401C5.18382 9.10927 5.18382 9.42585 5.37908 9.62111C5.57434 9.81637 5.89092 9.81637 6.08619 9.62111L9.62172 6.08558Z\""
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": " fill=\"currentColor\""
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": " ></path>"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "</symbol>"
},
{
"type": "UnchangedLine",
"lineBefore": 103,
"lineAfter": 117,
"content": "<symbol viewBox=\"0 0 15 15\" fill=\"none\" id=\"lock-closed\">"
},
{
"type": "UnchangedLine",
"lineBefore": 104,
"lineAfter": 118,
"content": " <path fill-rule=\"evenodd\""
},
{
"type": "UnchangedLine",
"lineBefore": 105,
"lineAfter": 119,
"content": " clip-rule=\"evenodd\""
}
]
},
{
"type": "Chunk",
"toFileRange": {
"start": 163,
"lines": 13
},
"fromFileRange": {
"start": 149,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 149,
"lineAfter": 163,
"content": " fill=\"currentColor\""
},
{
"type": "UnchangedLine",
"lineBefore": 150,
"lineAfter": 164,
"content": " ></path>"
},
{
"type": "UnchangedLine",
"lineBefore": 151,
"lineAfter": 165,
"content": "</symbol>"
},
{
"type": "AddedLine",
"lineAfter": 166,
"content": "<symbol viewBox=\"0 0 15 15\" fill=\"none\" id=\"question-mark-circled\">"
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": " <path fill-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": " clip-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": " d=\"M0.877075 7.49972C0.877075 3.84204 3.84222 0.876892 7.49991 0.876892C11.1576 0.876892 14.1227 3.84204 14.1227 7.49972C14.1227 11.1574 11.1576 14.1226 7.49991 14.1226C3.84222 14.1226 0.877075 11.1574 0.877075 7.49972ZM7.49991 1.82689C4.36689 1.82689 1.82708 4.36671 1.82708 7.49972C1.82708 10.6327 4.36689 13.1726 7.49991 13.1726C10.6329 13.1726 13.1727 10.6327 13.1727 7.49972C13.1727 4.36671 10.6329 1.82689 7.49991 1.82689ZM8.24993 10.5C8.24993 10.9142 7.91414 11.25 7.49993 11.25C7.08571 11.25 6.74993 10.9142 6.74993 10.5C6.74993 10.0858 7.08571 9.75 7.49993 9.75C7.91414 9.75 8.24993 10.0858 8.24993 10.5ZM6.05003 6.25C6.05003 5.57211 6.63511 4.925 7.50003 4.925C8.36496 4.925 8.95003 5.57211 8.95003 6.25C8.95003 6.74118 8.68002 6.99212 8.21447 7.27494C8.16251 7.30651 8.10258 7.34131 8.03847 7.37854L8.03841 7.37858C7.85521 7.48497 7.63788 7.61119 7.47449 7.73849C7.23214 7.92732 6.95003 8.23198 6.95003 8.7C6.95004 9.00376 7.19628 9.25 7.50004 9.25C7.8024 9.25 8.04778 9.00601 8.05002 8.70417L8.05056 8.7033C8.05924 8.6896 8.08493 8.65735 8.15058 8.6062C8.25207 8.52712 8.36508 8.46163 8.51567 8.37436L8.51571 8.37433C8.59422 8.32883 8.68296 8.27741 8.78559 8.21506C9.32004 7.89038 10.05 7.35382 10.05 6.25C10.05 4.92789 8.93511 3.825 7.50003 3.825C6.06496 3.825 4.95003 4.92789 4.95003 6.25C4.95003 6.55376 5.19628 6.8 5.50003 6.8C5.80379 6.8 6.05003 6.55376 6.05003 6.25Z\""
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": " fill=\"currentColor\""
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": " ></path>"
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "</symbol>"
},
{
"type": "UnchangedLine",
"lineBefore": 152,
"lineAfter": 173,
"content": "<symbol viewBox=\"0 0 15 15\" fill=\"none\" id=\"reset\">"
},
{
"type": "UnchangedLine",
"lineBefore": 153,
"lineAfter": 174,
"content": " <path fill-rule=\"evenodd\""
},
{
"type": "UnchangedLine",
"lineBefore": 154,
"lineAfter": 175,
"content": " clip-rule=\"evenodd\""
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/components/ui/icons/sprite.svg"
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "import {",
"type": "Chunk",
"toFileRange": {
"start": 21,
"lines": 8
},
"fromFileRange": {
"start": 21,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 21,
"lineAfter": 21,
"content": "\ttype V2_MetaFunction,"
},
{
"type": "UnchangedLine",
"lineBefore": 22,
"lineAfter": 22,
"content": "} from '@remix-run/react'"
},
{
"type": "UnchangedLine",
"lineBefore": 23,
"lineAfter": 23,
"content": "import os from 'node:os'"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "import { useEffect } from 'react'"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "import { Toaster, toast as showToast } from 'sonner'"
},
{
"type": "UnchangedLine",
"lineBefore": 24,
"lineAfter": 26,
"content": "import { z } from 'zod'"
},
{
"type": "UnchangedLine",
"lineBefore": 25,
"lineAfter": 27,
"content": "import faviconAssetUrl from './assets/favicon.svg'"
},
{
"type": "UnchangedLine",
"lineBefore": 26,
"lineAfter": 28,
"content": "import { GeneralErrorBoundary } from './components/error-boundary.tsx'"
}
]
},
{
"context": "import { getUserId } from './utils/auth.server.ts'",
"type": "Chunk",
"toFileRange": {
"start": 38,
"lines": 10
},
"fromFileRange": {
"start": 36,
"lines": 9
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 36,
"lineAfter": 38,
"content": "import { prisma } from './utils/db.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 37,
"lineAfter": 39,
"content": "import { getEnv } from './utils/env.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 38,
"lineAfter": 40,
"content": "import { getUserImgSrc, invariantResponse } from './utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "import { userHasRole } from './utils/permissions.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 39,
"lineAfter": 42,
"content": "import { getTheme, setTheme, type Theme } from './utils/theme.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "import { type Toast, getToast } from './utils/toast.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 40,
"lineAfter": 44,
"content": "import { useOptionalUser } from './utils/user.ts'"
},
{
"type": "DeletedLine",
"lineBefore": 41,
"content": "import { userHasRole } from './utils/permissions.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 42,
"lineAfter": 45,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 43,
"lineAfter": 46,
"content": "export const links: LinksFunction = () => {"
},
{
"type": "UnchangedLine",
"lineBefore": 44,
"lineAfter": 47,
"content": "\treturn ["
}
]
},
{
"context": "export const links: LinksFunction = () => {",
"type": "Chunk",
"toFileRange": {
"start": 55,
"lines": 7
},
"fromFileRange": {
"start": 52,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 52,
"lineAfter": 55,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 53,
"lineAfter": 56,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 54,
"lineAfter": 57,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\tconst { toast, headers: toastHeaders } = await getToast(request)"
},
{
"type": "UnchangedLine",
"lineBefore": 55,
"lineAfter": 59,
"content": "\tconst userId = await getUserId(request)"
},
{
"type": "UnchangedLine",
"lineBefore": 56,
"lineAfter": 60,
"content": "\tconst user = userId"
},
{
"type": "UnchangedLine",
"lineBefore": 57,
"lineAfter": 61,
"content": "\t\t? await prisma.user.findUniqueOrThrow({"
}
]
},
{
"context": "export async function loader({ request }: DataFunctionArgs) {",
"type": "Chunk",
"toFileRange": {
"start": 76,
"lines": 16
},
"fromFileRange": {
"start": 72,
"lines": 12
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 72,
"lineAfter": 76,
"content": "\t\t\t\twhere: { id: userId },"
},
{
"type": "UnchangedLine",
"lineBefore": 73,
"lineAfter": 77,
"content": "\t\t })"
},
{
"type": "UnchangedLine",
"lineBefore": 74,
"lineAfter": 78,
"content": "\t\t: null"
},
{
"type": "DeletedLine",
"lineBefore": 75,
"content": "\treturn json({"
},
{
"type": "DeletedLine",
"lineBefore": 76,
"content": "\t\tusername: os.userInfo().username,"
},
{
"type": "DeletedLine",
"lineBefore": 77,
"content": "\t\tuser,"
},
{
"type": "DeletedLine",
"lineBefore": 78,
"content": "\t\ttheme: getTheme(request),"
},
{
"type": "DeletedLine",
"lineBefore": 79,
"content": "\t\tENV: getEnv(),"
},
{
"type": "DeletedLine",
"lineBefore": 80,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "\treturn json("
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\t\tusername: os.userInfo().username,"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t\t\tuser,"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\t\t\ttheme: getTheme(request),"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "\t\t\ttoast,"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\t\t\tENV: getEnv(),"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\t\t{ headers: toastHeaders },"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 81,
"lineAfter": 89,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 82,
"lineAfter": 90,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 83,
"lineAfter": 91,
"content": "const ThemeFormSchema = z.object({"
}
]
},
{
"context": "function Document({",
"type": "Chunk",
"toFileRange": {
"start": 140,
"lines": 7
},
"fromFileRange": {
"start": 132,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 132,
"lineAfter": 140,
"content": "\t\t\t\t\t\t__html: `window.ENV = ${JSON.stringify(env)}`,"
},
{
"type": "UnchangedLine",
"lineBefore": 133,
"lineAfter": 141,
"content": "\t\t\t\t\t}}"
},
{
"type": "UnchangedLine",
"lineBefore": 134,
"lineAfter": 142,
"content": "\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\t\t\t\t<Toaster closeButton position=\"top-center\" />"
},
{
"type": "UnchangedLine",
"lineBefore": 135,
"lineAfter": 144,
"content": "\t\t\t\t<ScrollRestoration />"
},
{
"type": "UnchangedLine",
"lineBefore": 136,
"lineAfter": 145,
"content": "\t\t\t\t<Scripts />"
},
{
"type": "UnchangedLine",
"lineBefore": 137,
"lineAfter": 146,
"content": "\t\t\t\t<KCDShop />"
}
]
},
{
"context": "export default function App() {",
"type": "Chunk",
"toFileRange": {
"start": 220,
"lines": 7
},
"fromFileRange": {
"start": 211,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 211,
"lineAfter": 220,
"content": "\t\t\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 212,
"lineAfter": 221,
"content": "\t\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 213,
"lineAfter": 222,
"content": "\t\t\t<Spacer size=\"3xs\" />"
},
{
"type": "AddedLine",
"lineAfter": 223,
"content": "\t\t\t{data.toast ? <ShowToast toast={data.toast} /> : null}"
},
{
"type": "UnchangedLine",
"lineBefore": 214,
"lineAfter": 224,
"content": "\t\t</Document>"
},
{
"type": "UnchangedLine",
"lineBefore": 215,
"lineAfter": 225,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 216,
"lineAfter": 226,
"content": "}"
}
]
},
{
"context": "function ThemeSwitch({ userPreference }: { userPreference?: Theme }) {",
"type": "Chunk",
"toFileRange": {
"start": 271,
"lines": 7
},
"fromFileRange": {
"start": 261,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 261,
"lineAfter": 271,
"content": "\t\t\t\t<button"
},
{
"type": "UnchangedLine",
"lineBefore": 262,
"lineAfter": 272,
"content": "\t\t\t\t\tname=\"intent\""
},
{
"type": "UnchangedLine",
"lineBefore": 263,
"lineAfter": 273,
"content": "\t\t\t\t\tvalue=\"update-theme\""
},
{
"type": "AddedLine",
"lineAfter": 274,
"content": "\t\t\t\t\ttype=\"submit\""
},
{
"type": "UnchangedLine",
"lineBefore": 264,
"lineAfter": 275,
"content": "\t\t\t\t\tclassName=\"flex h-8 w-8 cursor-pointer items-center justify-center\""
},
{
"type": "UnchangedLine",
"lineBefore": 265,
"lineAfter": 276,
"content": "\t\t\t\t>"
},
{
"type": "UnchangedLine",
"lineBefore": 266,
"lineAfter": 277,
"content": "\t\t\t\t\t{modeLabel[mode]}"
}
]
},
{
"context": "function ThemeSwitch({ userPreference }: { userPreference?: Theme }) {",
"type": "Chunk",
"toFileRange": {
"start": 282,
"lines": 16
},
"fromFileRange": {
"start": 271,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 271,
"lineAfter": 282,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 272,
"lineAfter": 283,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 273,
"lineAfter": 284,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 285,
"content": "function ShowToast({ toast }: { toast: Toast }) {"
},
{
"type": "AddedLine",
"lineAfter": 286,
"content": "\tconst { id, type, title, description } = toast"
},
{
"type": "AddedLine",
"lineAfter": 287,
"content": "\tuseEffect(() => {"
},
{
"type": "AddedLine",
"lineAfter": 288,
"content": "\t\tsetTimeout(() => {"
},
{
"type": "AddedLine",
"lineAfter": 289,
"content": "\t\t\tshowToast[type](title, { id, description })"
},
{
"type": "AddedLine",
"lineAfter": 290,
"content": "\t\t}, 0)"
},
{
"type": "AddedLine",
"lineAfter": 291,
"content": "\t}, [description, id, title, type])"
},
{
"type": "AddedLine",
"lineAfter": 292,
"content": "\treturn null"
},
{
"type": "AddedLine",
"lineAfter": 293,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 294,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 274,
"lineAfter": 295,
"content": "export const meta: V2_MetaFunction = () => {"
},
{
"type": "UnchangedLine",
"lineBefore": 275,
"lineAfter": 296,
"content": "\treturn ["
},
{
"type": "UnchangedLine",
"lineBefore": 276,
"lineAfter": 297,
"content": "\t\t{ title: 'Epic Notes' },"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/root.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 179
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { redirect, type DataFunctionArgs } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { GitHubStrategy } from 'remix-auth-github'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "\tSESSION_EXPIRATION_TIME,"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "\tauthenticator,"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\tgetUserId,"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "} from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import { combineHeaders } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "\tdestroyRedirectToHeader,"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\tgetRedirectCookieValue,"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "} from '~/utils/redirect-cookie.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "import { sessionStorage } from '~/utils/session.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "import { createToastHeaders, redirectWithToast } from '~/utils/toast.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "import { verifySessionStorage } from '~/utils/verification.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "import { handleNewSession } from './login.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "\tgithubIdKey,"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "\tonboardingEmailSessionKey,"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "\tprefilledProfileKey,"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "} from './onboarding_.github.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "const destroyRedirectTo = { 'set-cookie': destroyRedirectToHeader }"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "async function makeSession("
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "\t{"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\t\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\t\tuserId,"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\t\tredirectTo,"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\t}: { request: Request; userId: string; redirectTo?: string | null },"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "\tresponseInit?: ResponseInit,"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": ") {"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\tredirectTo ??= '/'"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\tconst session = await prisma.session.create({"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\t\tselect: { id: true, expirationDate: true, userId: true },"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t\tdata: {"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t\t\texpirationDate: new Date(Date.now() + SESSION_EXPIRATION_TIME),"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t\t\tuserId,"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\treturn handleNewSession("
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\t{ request, session, redirectTo, remember: true },"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t\t{ headers: combineHeaders(responseInit?.headers, destroyRedirectTo) },"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\tconst reqUrl = new URL(request.url)"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\tconst redirectTo = getRedirectCookieValue(request)"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\tif ("
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\tprocess.env.GITHUB_CLIENT_ID.startsWith('MOCK_') &&"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t\treqUrl.searchParams.get('state') === 'MOCK_STATE'"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t) {"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\t\tconst cookieSession = await sessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t\tconst state = cookieSession.get('oauth2:state') ?? 'MOCK_STATE'"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\t\tcookieSession.set('oauth2:state', state)"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t\treqUrl.searchParams.set('state', state)"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t\trequest.headers.set("
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\t\t\t'cookie',"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t\t\tawait sessionStorage.commitSession(cookieSession),"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\t\trequest = new Request(reqUrl.toString(), request)"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\tconst authResult = await authenticator"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\t\t.authenticate(GitHubStrategy.name, request, { throwOnError: true })"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\t\t.then("
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\t\t\tdata => ({ success: true, data }) as const,"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t\t\terror => ({ success: false, error }) as const,"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\tif (!authResult.success) {"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "\t\tconsole.error(authResult.error)"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\t\treturn redirectWithToast("
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\t\t\t'/login',"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "\t\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\t\t\t\ttitle: 'Auth Failed',"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\t\t\tdescription: 'There was an error authenticating with GitHub.',"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t\t\t\ttype: 'error',"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "\t\t\t{ headers: destroyRedirectTo },"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\tconst { data: profile } = authResult"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\tconst existingConnection = await prisma.gitHubConnection.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\t\tselect: { userId: true },"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\t\twhere: { providerId: profile.id },"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\tconst userId = await getUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\tif (existingConnection && userId) {"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\t\tif (existingConnection.userId === userId) {"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "\t\t\treturn redirectWithToast("
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\t\t\t\t'/settings/profile/connections',"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t\t\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\t\t\t\t\ttitle: 'Already Connected',"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": "\t\t\t\t\tdescription: `Your \"${profile.username}\" GitHub account is already connected.`,"
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": "\t\t\t\t{ headers: destroyRedirectTo },"
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "\t\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\t\t} else {"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "\t\t\treturn redirectWithToast("
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": "\t\t\t\t'/settings/profile/connections',"
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "\t\t\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "\t\t\t\t\ttitle: 'Already Connected',"
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": "\t\t\t\t\tdescription: `The \"${profile.username}\" GitHub account is already connected to another account.`,"
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": "\t\t\t\t{ headers: destroyRedirectTo },"
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": "\t\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\t// If we're already logged in, then link the GitHub account"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\tif (userId) {"
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\t\tawait prisma.gitHubConnection.create({"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "\t\t\tdata: { providerId: profile.id, userId },"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\t\treturn redirectWithToast("
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t\t\t'/settings/profile/connections',"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": "\t\t\t\ttitle: 'Connected',"
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "\t\t\t\tdescription: `Your \"${profile.username}\" GitHub account has been connected.`,"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\t\t\t{ headers: destroyRedirectTo },"
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\t// Connection exists already? Make a new session"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\tif (existingConnection) {"
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": "\t\treturn makeSession({ request, userId: existingConnection.userId })"
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": "\t// if the github email matches a user in the db, then link the account and"
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "\t// make a new session"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\tconst user = await prisma.user.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\t\tselect: { id: true },"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\t\twhere: { email: profile.email },"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": "\tif (user) {"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "\t\tawait prisma.gitHubConnection.create({"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": "\t\t\tdata: { providerId: profile.id, userId: user.id },"
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "\t\treturn makeSession("
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "\t\t\t{ request, userId: user.id },"
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "\t\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "\t\t\t\theaders: await createToastHeaders({"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "\t\t\t\t\ttitle: 'Connected',"
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": "\t\t\t\t\tdescription: `Your \"${profile.username}\" GitHub account has been connected.`,"
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "\t\t\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": "\t// this is a new user, so let's get them onboarded"
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 163,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 164,
"content": "\tverifySession.set(onboardingEmailSessionKey, profile.email)"
},
{
"type": "AddedLine",
"lineAfter": 165,
"content": "\tverifySession.set(prefilledProfileKey, profile)"
},
{
"type": "AddedLine",
"lineAfter": 166,
"content": "\tverifySession.set(githubIdKey, profile.id)"
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": "\tconst onboardingRedirect = ["
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": "\t\t'/onboarding/github',"
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": "\t\tredirectTo ? new URLSearchParams({ redirectTo }) : null,"
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": "\t]"
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "\t\t.filter(Boolean)"
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "\t\t.join('?')"
},
{
"type": "AddedLine",
"lineAfter": 173,
"content": "\tthrow redirect(onboardingRedirect, {"
},
{
"type": "AddedLine",
"lineAfter": 174,
"content": "\t\theaders: combineHeaders("
},
{
"type": "AddedLine",
"lineAfter": 175,
"content": "\t\t\t{ 'set-cookie': await verifySessionStorage.commitSession(verifySession) },"
},
{
"type": "AddedLine",
"lineAfter": 176,
"content": "\t\t\tdestroyRedirectTo,"
},
{
"type": "AddedLine",
"lineAfter": 177,
"content": "\t\t),"
},
{
"type": "AddedLine",
"lineAfter": 178,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 179,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/auth.github.callback.ts"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 34
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { redirect, type DataFunctionArgs } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { GitHubStrategy } from 'remix-auth-github'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import { authenticator } from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "import { getReferrerRoute } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "import { getRedirectCookieHeader } from '~/utils/redirect-cookie.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "export async function loader() {"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "\treturn redirect('/login')"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\tconst formData = await request.formData()"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\tconst rawRedirectTo = formData.get('redirectTo')"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "\tconst redirectTo ="
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "\t\ttypeof rawRedirectTo === 'string'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "\t\t\t? rawRedirectTo"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "\t\t\t: getReferrerRoute(request)"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "\tconst redirectToCookie = getRedirectCookieHeader(redirectTo)"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "\tif (process.env.GITHUB_CLIENT_ID.startsWith('MOCK_')) {"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "\t\treturn redirect(`/auth/github/callback?code=MOCK_CODE&state=MOCK_STATE`, {"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "\t\t\theaders: redirectToCookie ? { 'set-cookie': redirectToCookie } : {},"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\ttry {"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "\t\treturn await authenticator.authenticate(GitHubStrategy.name, request)"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\t} catch (error: unknown) {"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\t\tif (error instanceof Response && redirectToCookie) {"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\t\t\terror.headers.append('set-cookie', redirectToCookie)"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "\t\tthrow error"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/auth.github.ts"
},
{
"type": "ChangedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 184
},
"fromFileRange": {
"start": 1,
"lines": 14
},
"changes": [
{
"type": "DeletedLine",
"lineBefore": 1,
"content": "export default function ForgotPassword() {"
},
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { conform, useForm } from '@conform-to/react'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { getFieldsetConstraint, parse } from '@conform-to/zod'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import * as E from '@react-email/components'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "\tjson,"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\tredirect,"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\ttype DataFunctionArgs,"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "\ttype V2_MetaFunction,"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "} from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "import { Link, useFetcher } from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "import { ErrorList, Field } from '~/components/forms.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "import { sendEmail } from '~/utils/email.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "import { emailSchema, usernameSchema } from '~/utils/user-validation.ts'"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "import { prepareVerification } from './verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "const ForgotPasswordSchema = z.object({"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "\tusernameOrEmail: z.union([emailSchema, usernameSchema]),"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "})"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "\tconst formData = await request.formData()"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\tconst submission = await parse(formData, {"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "\t\tschema: ForgotPasswordSchema.superRefine(async (data, ctx) => {"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\t\t\tconst user = await prisma.user.findFirst({"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\t\t\t\twhere: {"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\t\t\t\t\tOR: ["
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\t\t\t\t\t\t{ email: data.usernameOrEmail },"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "\t\t\t\t\t\t{ username: data.usernameOrEmail },"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "\t\t\t\t\t],"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\t\t\t\tselect: { id: true },"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t\t\tif (!user) {"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t\t\t\tctx.addIssue({"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t\t\t\t\tpath: ['usernameOrEmail'],"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t\t\t\t\tcode: z.ZodIssueCode.custom,"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t\t\t\t\tmessage: 'No user exists with this username or email',"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\t\t\treturn"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\t\tasync: true,"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\t\tacceptMultipleErrors: () => true,"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\tif (submission.intent !== 'submit') {"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\t\treturn json({ status: 'idle', submission } as const)"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\tif (!submission.value) {"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\tconst { usernameOrEmail } = submission.value"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\tconst user = await prisma.user.findFirstOrThrow({"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t\twhere: { OR: [{ email: usernameOrEmail }, { username: usernameOrEmail }] },"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\t\tselect: { email: true, username: true },"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\tconst { verifyUrl, redirectTo, otp } = await prepareVerification({"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t\tperiod: 10 * 60,"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\t\ttype: 'reset-password',"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\t\ttarget: usernameOrEmail,"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\tconst response = await sendEmail({"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\t\tto: user.email,"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\t\tsubject: `Epic Notes Password Reset`,"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t\treact: ("
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\t\t\t<ForgotPasswordEmail onboardingUrl={verifyUrl.toString()} otp={otp} />"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\t\t),"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\tif (response.status === 'success') {"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\t\treturn redirect(redirectTo.toString())"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "\t} else {"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\t\tsubmission.error[''] = response.error.message"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 500 })"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "function ForgotPasswordEmail({"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\tonboardingUrl,"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\totp,"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "}: {"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "\tonboardingUrl: string"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\totp: string"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "}) {"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\t\t<E.Html lang=\"en\" dir=\"ltr\">"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\t\t\t<E.Container>"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t\t\t\t<h1>"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "\t\t\t\t\t<E.Text>Epic Notes Password Reset</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\t\t\t\t</h1>"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "\t\t\t\t\t<E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\t\t\t\t\t\tHere's your verification code: <strong>{otp}</strong>"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t\t\t\t\t</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": "\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "\t\t\t\t\t<E.Text>Or click the link:</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "\t\t\t\t<E.Link href={onboardingUrl}>{onboardingUrl}</E.Link>"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\t\t\t</E.Container>"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "\t\t</E.Html>"
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": "export const meta: V2_MetaFunction = () => {"
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "\treturn [{ title: 'Password Recovery for Epic Notes' }]"
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "export default function ForgotPasswordRoute() {"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "\tconst forgotPassword = useFetcher<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\tconst [form, fields] = useForm({"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\t\tid: 'forgot-password-form',"
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\t\tconstraint: getFieldsetConstraint(ForgotPasswordSchema),"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "\t\tlastSubmission: forgotPassword.data?.submission,"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\t\tonValidate({ formData }) {"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\t\t\treturn parse(formData, { schema: ForgotPasswordSchema })"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t\tshouldRevalidate: 'onBlur',"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 2,
"lineAfter": 129,
"content": "\treturn ("
},
{
"type": "UnchangedLine",
"lineBefore": 3,
"lineAfter": 130,
"content": "\t\t<div className=\"container pb-32 pt-20\">"
},
{
"type": "UnchangedLine",
"lineBefore": 4,
"lineAfter": 131,
"content": "\t\t\t<div className=\"flex flex-col justify-center\">"
},
{
"type": "UnchangedLine",
"lineBefore": 5,
"lineAfter": 132,
"content": "\t\t\t\t<div className=\"text-center\">"
},
{
"type": "UnchangedLine",
"lineBefore": 6,
"lineAfter": 133,
"content": "\t\t\t\t\t<h1 className=\"text-h1\">Forgot Password</h1>"
},
{
"type": "UnchangedLine",
"lineBefore": 7,
"lineAfter": 134,
"content": "\t\t\t\t\t<p className=\"mt-3 text-body-md text-muted-foreground\">"
},
{
"type": "DeletedLine",
"lineBefore": 8,
"content": "\t\t\t\t\t\tNo worries, shoot support an email and we'll get you fixed up."
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\t\t\t\t\t\tNo worries, we'll send you reset instructions."
},
{
"type": "UnchangedLine",
"lineBefore": 9,
"lineAfter": 136,
"content": "\t\t\t\t\t</p>"
},
{
"type": "UnchangedLine",
"lineBefore": 10,
"lineAfter": 137,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "\t\t\t\t<div className=\"mx-auto mt-16 min-w-[368px] max-w-sm\">"
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": "\t\t\t\t\t<forgotPassword.Form method=\"POST\" {...form.props}>"
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "\t\t\t\t\t\t<div>"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\t\t\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\t\t\t\t\t\t\t\tlabelProps={{"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\t\t\t\t\t\t\t\t\thtmlFor: fields.usernameOrEmail.id,"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "\t\t\t\t\t\t\t\t\tchildren: 'Username or Email',"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": "\t\t\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "\t\t\t\t\t\t\t\tinputProps={{"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": "\t\t\t\t\t\t\t\t\tautoFocus: true,"
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\t\t\t\t\t\t\t\t\t...conform.input(fields.usernameOrEmail),"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "\t\t\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "\t\t\t\t\t\t\t\terrors={fields.usernameOrEmail.errors}"
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "\t\t\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "\t\t\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "\t\t\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />"
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "\t\t\t\t\t\t<div className=\"mt-6\">"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "\t\t\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\t\t\t\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "\t\t\t\t\t\t\t\tstatus={"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": "\t\t\t\t\t\t\t\t\tforgotPassword.state === 'submitting'"
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": "\t\t\t\t\t\t\t\t\t\t? 'pending'"
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": "\t\t\t\t\t\t\t\t\t\t: forgotPassword.data?.status ?? 'idle'"
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": "\t\t\t\t\t\t\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 163,
"content": "\t\t\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 164,
"content": "\t\t\t\t\t\t\t\tdisabled={forgotPassword.state !== 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 165,
"content": "\t\t\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 166,
"content": "\t\t\t\t\t\t\t\tRecover password"
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": "\t\t\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": "\t\t\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": "\t\t\t\t\t</forgotPassword.Form>"
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": "\t\t\t\t\t<Link"
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "\t\t\t\t\t\tto=\"/login\""
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "\t\t\t\t\t\tclassName=\"mt-11 text-center text-body-sm font-bold\""
},
{
"type": "AddedLine",
"lineAfter": 173,
"content": "\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 174,
"content": "\t\t\t\t\t\tBack to Login"
},
{
"type": "AddedLine",
"lineAfter": 175,
"content": "\t\t\t\t\t</Link>"
},
{
"type": "AddedLine",
"lineAfter": 176,
"content": "\t\t\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 11,
"lineAfter": 177,
"content": "\t\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 12,
"lineAfter": 178,
"content": "\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 13,
"lineAfter": 179,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 14,
"lineAfter": 180,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 181,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 182,
"content": "export function ErrorBoundary() {"
},
{
"type": "AddedLine",
"lineAfter": 183,
"content": "\treturn <GeneralErrorBoundary />"
},
{
"type": "AddedLine",
"lineAfter": 184,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/forgot-password.tsx"
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'",
"type": "Chunk",
"toFileRange": {
"start": 13,
"lines": 141
},
"fromFileRange": {
"start": 13,
"lines": 11
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 13,
"lineAfter": 13,
"content": "import { CheckboxField, ErrorList, Field } from '~/components/forms.tsx'"
},
{
"type": "UnchangedLine",
"lineBefore": 14,
"lineAfter": 14,
"content": "import { Spacer } from '~/components/spacer.tsx'"
},
{
"type": "UnchangedLine",
"lineBefore": 15,
"lineAfter": 15,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "DeletedLine",
"lineBefore": 16,
"content": "import { login, requireAnonymous, sessionKey } from '~/utils/auth.server.ts'"
},
{
"type": "DeletedLine",
"lineBefore": 17,
"content": "import { useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "DeletedLine",
"lineBefore": 18,
"content": "import { commitSession, getSession } from '~/utils/session.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "\tgetUserId,"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "\tlogin,"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "\trequireAnonymous,"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "\tsessionKey,"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "} from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "import { combineResponseInits, invariant, useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "import { sessionStorage } from '~/utils/session.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 19,
"lineAfter": 25,
"content": "import { passwordSchema, usernameSchema } from '~/utils/user-validation.ts'"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "import { verifySessionStorage } from '~/utils/verification.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 20,
"lineAfter": 27,
"content": "import { checkboxSchema } from '~/utils/zod-extensions.ts'"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "import { twoFAVerificationType } from '../settings+/profile.two-factor.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "import { getRedirectToUrl, type VerifyFunctionArgs } from './verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "const verifiedTimeKey = 'verified-time'"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "const unverifiedSessionIdKey = 'unverified-session-id'"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "export async function handleNewSession("
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\t{"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\t\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t\tsession,"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t\tredirectTo,"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t\tremember,"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t}: {"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t\trequest: Request"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t\tsession: { userId: string; id: string; expirationDate: Date }"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\tredirectTo?: string"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t\tremember: boolean"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t},"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\tresponseInit?: ResponseInit,"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": ") {"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\tconst verification = await prisma.verification.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\t\tselect: { id: true },"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\t\twhere: {"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t\t\ttarget_type: { target: session.userId, type: twoFAVerificationType },"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\tconst userHasTwoFactor = Boolean(verification)"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\tif (userHasTwoFactor) {"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t\tverifySession.set(unverifiedSessionIdKey, session.id)"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t\tconst redirectUrl = getRedirectToUrl({"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\t\t\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t\t\ttype: twoFAVerificationType,"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t\t\ttarget: session.userId,"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\t\treturn redirect("
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\t\t\tredirectUrl.toString(),"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\t\t\tcombineResponseInits("
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\t\t\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\t\t\t\t\theaders: {"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\t\t\t\t\t\t'set-cookie': await verifySessionStorage.commitSession("
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t\t\t\t\t\t\tverifySession,"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\t\t\t\t\t\t),"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\t\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "\t\t\t\tresponseInit,"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\t\t\t),"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "\t} else {"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\t\tconst cookieSession = await sessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\t\tcookieSession.set(sessionKey, session.id)"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\t\treturn redirect("
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\t\t\tsafeRedirect(redirectTo),"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\t\t\tcombineResponseInits("
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\t\t\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "\t\t\t\t\theaders: {"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\t\t\t\t\t\t'set-cookie': await sessionStorage.commitSession(cookieSession, {"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\t\t\t\t\t\t\texpires: remember ? session.expirationDate : undefined,"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\t\t\t\t\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\t\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t\t\t\tresponseInit,"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "\t\t\t),"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "export async function handleVerification({"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": "\tsubmission,"
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "}: VerifyFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": "\tinvariant(submission.value, 'Submission should have a value by this point')"
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "\tconst cookieSession = await sessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": "\tif (verifySession.has(unverifiedSessionIdKey)) {"
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "\t\tcookieSession.set(sessionKey, verifySession.get(unverifiedSessionIdKey))"
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": "\tconst { redirectTo } = submission.value"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "\tcookieSession.set(verifiedTimeKey, Date.now())"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": "\tconst headers = new Headers()"
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\theaders.append("
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\t\t'set-cookie',"
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\t\tawait sessionStorage.commitSession(cookieSession),"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\theaders.append("
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\t\t'set-cookie',"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t\tawait verifySessionStorage.destroySession(verifySession),"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "\treturn redirect(safeRedirect(redirectTo), { headers })"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": "export async function shouldRequestTwoFA(request: Request) {"
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\tconst cookieSession = await sessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "\tif (verifySession.has(unverifiedSessionIdKey)) return true"
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": "\tconst userId = await getUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "\tif (!userId) return false"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\t// if it's over two hours since they last verified, we should request 2FA again"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\tconst userHasTwoFA = await prisma.verification.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\t\tselect: { id: true },"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "\t\twhere: { target_type: { target: userId, type: twoFAVerificationType } },"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "\tif (!userHasTwoFA) return false"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": "\tconst verifiedTime = cookieSession.get(verifiedTimeKey) ?? new Date(0)"
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\tconst twoHours = 1000 * 60 * 60 * 2"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "\treturn Date.now() - verifiedTime > twoHours"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 21,
"lineAfter": 151,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 22,
"lineAfter": 152,
"content": "const LoginFormSchema = z.object({"
},
{
"type": "UnchangedLine",
"lineBefore": 23,
"lineAfter": 153,
"content": "\tusername: usernameSchema,"
}
]
},
{
"context": "export async function action({ request }: DataFunctionArgs) {",
"type": "Chunk",
"toFileRange": {
"start": 196,
"lines": 13
},
"fromFileRange": {
"start": 66,
"lines": 22
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 66,
"lineAfter": 196,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 67,
"lineAfter": 197,
"content": "\tconst { session, remember, redirectTo } = submission.value"
},
{
"type": "UnchangedLine",
"lineBefore": 68,
"lineAfter": 198,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 69,
"content": "\tconst cookieSession = await getSession(request.headers.get('cookie'))"
},
{
"type": "DeletedLine",
"lineBefore": 70,
"content": "\tcookieSession.set(sessionKey, session.id)"
},
{
"type": "DeletedLine",
"lineBefore": 71,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 72,
"content": "\treturn redirect(safeRedirect(redirectTo), {"
},
{
"type": "DeletedLine",
"lineBefore": 73,
"content": "\t\theaders: {"
},
{
"type": "DeletedLine",
"lineBefore": 74,
"content": "\t\t\t'set-cookie': await commitSession(cookieSession, {"
},
{
"type": "DeletedLine",
"lineBefore": 75,
"content": "\t\t\t\t// Cookies with no expiration are cleared when the tab/window closes"
},
{
"type": "DeletedLine",
"lineBefore": 76,
"content": "\t\t\t\texpires: remember ? session.expirationDate : undefined,"
},
{
"type": "DeletedLine",
"lineBefore": 77,
"content": "\t\t\t}),"
},
{
"type": "DeletedLine",
"lineBefore": 78,
"content": "\t\t},"
},
{
"type": "DeletedLine",
"lineBefore": 79,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 199,
"content": "\treturn handleNewSession({ request, session, remember, redirectTo })"
},
{
"type": "UnchangedLine",
"lineBefore": 80,
"lineAfter": 200,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 81,
"lineAfter": 201,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 82,
"lineAfter": 202,
"content": "export default function LoginPage() {"
},
{
"type": "UnchangedLine",
"lineBefore": 83,
"lineAfter": 203,
"content": "\tconst actionData = useActionData<typeof action>()"
},
{
"type": "UnchangedLine",
"lineBefore": 84,
"lineAfter": 204,
"content": "\tconst isPending = useIsPending()"
},
{
"type": "AddedLine",
"lineAfter": 205,
"content": "\tconst isGitHubSubmitting = useIsPending({ formAction: '/auth/github' })"
},
{
"type": "UnchangedLine",
"lineBefore": 85,
"lineAfter": 206,
"content": "\tconst [searchParams] = useSearchParams()"
},
{
"type": "UnchangedLine",
"lineBefore": 86,
"lineAfter": 207,
"content": "\tconst redirectTo = searchParams.get('redirectTo')"
},
{
"type": "UnchangedLine",
"lineBefore": 87,
"lineAfter": 208,
"content": ""
}
]
},
{
"context": "export default function LoginPage() {",
"type": "Chunk",
"toFileRange": {
"start": 271,
"lines": 9
},
"fromFileRange": {
"start": 150,
"lines": 7
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 150,
"lineAfter": 271,
"content": "\t\t\t\t\t\t\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 151,
"lineAfter": 272,
"content": "\t\t\t\t\t\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 152,
"lineAfter": 273,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 153,
"content": "\t\t\t\t\t\t\t<input {...conform.input(fields.redirectTo)} type=\"hidden\" />"
},
{
"type": "AddedLine",
"lineAfter": 274,
"content": "\t\t\t\t\t\t\t<input"
},
{
"type": "AddedLine",
"lineAfter": 275,
"content": "\t\t\t\t\t\t\t\t{...conform.input(fields.redirectTo, { type: 'hidden' })}"
},
{
"type": "AddedLine",
"lineAfter": 276,
"content": "\t\t\t\t\t\t\t/>"
},
{
"type": "UnchangedLine",
"lineBefore": 154,
"lineAfter": 277,
"content": "\t\t\t\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />"
},
{
"type": "UnchangedLine",
"lineBefore": 155,
"lineAfter": 278,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 156,
"lineAfter": 279,
"content": "\t\t\t\t\t\t\t<div className=\"flex items-center justify-between gap-6 pt-3\">"
}
]
},
{
"context": "export default function LoginPage() {",
"type": "Chunk",
"toFileRange": {
"start": 287,
"lines": 24
},
"fromFileRange": {
"start": 164,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 164,
"lineAfter": 287,
"content": "\t\t\t\t\t\t\t\t</StatusButton>"
},
{
"type": "UnchangedLine",
"lineBefore": 165,
"lineAfter": 288,
"content": "\t\t\t\t\t\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 166,
"lineAfter": 289,
"content": "\t\t\t\t\t\t</Form>"
},
{
"type": "AddedLine",
"lineAfter": 290,
"content": "\t\t\t\t\t\t<Form"
},
{
"type": "AddedLine",
"lineAfter": 291,
"content": "\t\t\t\t\t\t\tclassName=\"mt-5 flex items-center justify-center gap-2 border-t-2 border-border pt-3\""
},
{
"type": "AddedLine",
"lineAfter": 292,
"content": "\t\t\t\t\t\t\taction=\"/auth/github\""
},
{
"type": "AddedLine",
"lineAfter": 293,
"content": "\t\t\t\t\t\t\tmethod=\"POST\""
},
{
"type": "AddedLine",
"lineAfter": 294,
"content": "\t\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 295,
"content": "\t\t\t\t\t\t\t<input"
},
{
"type": "AddedLine",
"lineAfter": 296,
"content": "\t\t\t\t\t\t\t\ttype=\"hidden\""
},
{
"type": "AddedLine",
"lineAfter": 297,
"content": "\t\t\t\t\t\t\t\tname=\"redirectTo\""
},
{
"type": "AddedLine",
"lineAfter": 298,
"content": "\t\t\t\t\t\t\t\tvalue={redirectTo ?? '/'}"
},
{
"type": "AddedLine",
"lineAfter": 299,
"content": "\t\t\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 300,
"content": "\t\t\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 301,
"content": "\t\t\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 302,
"content": "\t\t\t\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "AddedLine",
"lineAfter": 303,
"content": "\t\t\t\t\t\t\t\tstatus={isGitHubSubmitting ? 'pending' : 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 304,
"content": "\t\t\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 305,
"content": "\t\t\t\t\t\t\t\tLogin with GitHub"
},
{
"type": "AddedLine",
"lineAfter": 306,
"content": "\t\t\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 307,
"content": "\t\t\t\t\t\t</Form>"
},
{
"type": "UnchangedLine",
"lineBefore": 167,
"lineAfter": 308,
"content": "\t\t\t\t\t\t<div className=\"flex items-center justify-center gap-2 pt-6\">"
},
{
"type": "UnchangedLine",
"lineBefore": 168,
"lineAfter": 309,
"content": "\t\t\t\t\t\t\t<span className=\"text-muted-foreground\">New here?</span>"
},
{
"type": "UnchangedLine",
"lineBefore": 169,
"lineAfter": 310,
"content": "\t\t\t\t\t\t\t<Link"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/login.tsx"
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "export async function loader() {",
"type": "Chunk",
"toFileRange": {
"start": 6,
"lines": 5
},
"fromFileRange": {
"start": 6,
"lines": 5
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 6,
"lineAfter": 6,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 7,
"lineAfter": 7,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 8,
"lineAfter": 8,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "DeletedLine",
"lineBefore": 9,
"content": "\treturn logout(request)"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "\treturn logout({ request })"
},
{
"type": "UnchangedLine",
"lineBefore": 10,
"lineAfter": 10,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/logout.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 257
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { conform, useForm } from '@conform-to/react'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { getFieldsetConstraint, parse } from '@conform-to/zod'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "\tjson,"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "\tredirect,"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\ttype DataFunctionArgs,"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\ttype V2_MetaFunction,"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "} from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "\tForm,"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "\tuseActionData,"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\tuseLoaderData,"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\tuseSearchParams,"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "} from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "import { safeRedirect } from 'remix-utils'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "import { CheckboxField, ErrorList, Field } from '~/components/forms.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "import { Spacer } from '~/components/spacer.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "import { requireAnonymous, sessionKey, signup } from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "import { invariant, useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "import { sessionStorage } from '~/utils/session.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "\tnameSchema,"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\tpasswordSchema,"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "\tusernameSchema,"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "} from '~/utils/user-validation.ts'"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "import { verifySessionStorage } from '~/utils/verification.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "import { checkboxSchema } from '~/utils/zod-extensions.ts'"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "import { type VerifyFunctionArgs } from './verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "const onboardingEmailSessionKey = 'onboardingEmail'"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "const SignupFormSchema = z"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\t.object({"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t\tusername: usernameSchema,"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t\tname: nameSchema,"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t\tpassword: passwordSchema,"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t\tconfirmPassword: passwordSchema,"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t\tagreeToTermsOfServiceAndPrivacyPolicy: checkboxSchema("
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t\t\t'You must agree to the terms of service and privacy policy',"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\t),"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t\tremember: checkboxSchema(),"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t\tredirectTo: z.string().optional(),"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\t.superRefine(({ confirmPassword, password }, ctx) => {"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\t\tif (confirmPassword !== password) {"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\t\t\tctx.addIssue({"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\t\t\t\tpath: ['confirmPassword'],"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t\t\t\tcode: 'custom',"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\t\t\tmessage: 'The passwords must match',"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "async function requireOnboardingEmail(request: Request) {"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\tawait requireAnonymous(request)"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\tconst email = verifySession.get(onboardingEmailSessionKey)"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\tif (typeof email !== 'string' || !email) {"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t\tthrow redirect('/signup')"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\treturn email"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\tconst email = await requireOnboardingEmail(request)"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\treturn json({ email })"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\tconst email = await requireOnboardingEmail(request)"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\tconst formData = await request.formData()"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "\tconst submission = await parse(formData, {"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\t\tschema: SignupFormSchema.superRefine(async (data, ctx) => {"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\t\t\tconst existingUser = await prisma.user.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "\t\t\t\twhere: { username: data.username },"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\t\t\t\tselect: { id: true },"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t\t\tif (existingUser) {"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\t\t\t\tctx.addIssue({"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "\t\t\t\t\tpath: ['username'],"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\t\t\t\t\tcode: z.ZodIssueCode.custom,"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\t\t\t\t\tmessage: 'A user already exists with this username',"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\t\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\t\t\t\treturn"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "\t\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\t\t}).transform(async data => {"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\t\t\tconst session = await signup({ ...data, email })"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\t\t\treturn { ...data, session }"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\t\tasync: true,"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\tif (submission.intent !== 'submit') {"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\t\treturn json({ status: 'idle', submission } as const)"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\tif (!submission.value?.session) {"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "\tconst { session, remember, redirectTo } = submission.value"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "\tconst cookieSession = await sessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": "\tcookieSession.set(sessionKey, session.id)"
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "\tconst headers = new Headers()"
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": "\theaders.append("
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": "\t\t'set-cookie',"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "\t\tawait sessionStorage.commitSession(cookieSession, {"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "\t\t\texpires: remember ? session.expirationDate : undefined,"
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": "\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\theaders.append("
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\t\t'set-cookie',"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "\t\tawait verifySessionStorage.destroySession(verifySession),"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\treturn redirect(safeRedirect(redirectTo), { headers })"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "export async function handleVerification({"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\tsubmission,"
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": "}: VerifyFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\tinvariant(submission.value, 'submission.value should be defined by now')"
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": "\tverifySession.set(onboardingEmailSessionKey, submission.value.target)"
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\treturn redirect('/onboarding', {"
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "\t\theaders: {"
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": "\t\t\t'set-cookie': await verifySessionStorage.commitSession(verifySession),"
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "export const meta: V2_MetaFunction = () => {"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": "\treturn [{ title: 'Setup Epic Notes Account' }]"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "export default function SignupRoute() {"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "\tconst data = useLoaderData<typeof loader>()"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "\tconst actionData = useActionData<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "\tconst isPending = useIsPending()"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "\tconst [searchParams] = useSearchParams()"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "\tconst redirectTo = searchParams.get('redirectTo')"
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "\tconst [form, fields] = useForm({"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "\t\tid: 'signup-form',"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\t\tconstraint: getFieldsetConstraint(SignupFormSchema),"
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "\t\tdefaultValue: { redirectTo },"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": "\t\tlastSubmission: actionData?.submission,"
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": "\t\tonValidate({ formData }) {"
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": "\t\t\treturn parse(formData, { schema: SignupFormSchema })"
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 163,
"content": "\t\tshouldRevalidate: 'onBlur',"
},
{
"type": "AddedLine",
"lineAfter": 164,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 165,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 166,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": "\t\t<div className=\"container flex min-h-full flex-col justify-center pb-32 pt-20\">"
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": "\t\t\t<div className=\"mx-auto w-full max-w-lg\">"
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": "\t\t\t\t<div className=\"flex flex-col gap-3 text-center\">"
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": "\t\t\t\t\t<h1 className=\"text-h1\">Welcome aboard {data.email}!</h1>"
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "\t\t\t\t\t<p className=\"text-body-md text-muted-foreground\">"
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "\t\t\t\t\t\tPlease enter your details."
},
{
"type": "AddedLine",
"lineAfter": 173,
"content": "\t\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 174,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 175,
"content": "\t\t\t\t<Spacer size=\"xs\" />"
},
{
"type": "AddedLine",
"lineAfter": 176,
"content": "\t\t\t\t<Form"
},
{
"type": "AddedLine",
"lineAfter": 177,
"content": "\t\t\t\t\tmethod=\"POST\""
},
{
"type": "AddedLine",
"lineAfter": 178,
"content": "\t\t\t\t\tclassName=\"mx-auto min-w-[368px] max-w-sm\""
},
{
"type": "AddedLine",
"lineAfter": 179,
"content": "\t\t\t\t\t{...form.props}"
},
{
"type": "AddedLine",
"lineAfter": 180,
"content": "\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 181,
"content": "\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 182,
"content": "\t\t\t\t\t\tlabelProps={{ htmlFor: fields.username.id, children: 'Username' }}"
},
{
"type": "AddedLine",
"lineAfter": 183,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "AddedLine",
"lineAfter": 184,
"content": "\t\t\t\t\t\t\t...conform.input(fields.username),"
},
{
"type": "AddedLine",
"lineAfter": 185,
"content": "\t\t\t\t\t\t\tautoComplete: 'username',"
},
{
"type": "AddedLine",
"lineAfter": 186,
"content": "\t\t\t\t\t\t\tclassName: 'lowercase',"
},
{
"type": "AddedLine",
"lineAfter": 187,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 188,
"content": "\t\t\t\t\t\terrors={fields.username.errors}"
},
{
"type": "AddedLine",
"lineAfter": 189,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 190,
"content": "\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 191,
"content": "\t\t\t\t\t\tlabelProps={{ htmlFor: fields.name.id, children: 'Name' }}"
},
{
"type": "AddedLine",
"lineAfter": 192,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "AddedLine",
"lineAfter": 193,
"content": "\t\t\t\t\t\t\t...conform.input(fields.name),"
},
{
"type": "AddedLine",
"lineAfter": 194,
"content": "\t\t\t\t\t\t\tautoComplete: 'name',"
},
{
"type": "AddedLine",
"lineAfter": 195,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 196,
"content": "\t\t\t\t\t\terrors={fields.name.errors}"
},
{
"type": "AddedLine",
"lineAfter": 197,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 198,
"content": "\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 199,
"content": "\t\t\t\t\t\tlabelProps={{ htmlFor: fields.password.id, children: 'Password' }}"
},
{
"type": "AddedLine",
"lineAfter": 200,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "AddedLine",
"lineAfter": 201,
"content": "\t\t\t\t\t\t\t...conform.input(fields.password, { type: 'password' }),"
},
{
"type": "AddedLine",
"lineAfter": 202,
"content": "\t\t\t\t\t\t\tautoComplete: 'new-password',"
},
{
"type": "AddedLine",
"lineAfter": 203,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 204,
"content": "\t\t\t\t\t\terrors={fields.password.errors}"
},
{
"type": "AddedLine",
"lineAfter": 205,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 206,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 207,
"content": "\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 208,
"content": "\t\t\t\t\t\tlabelProps={{"
},
{
"type": "AddedLine",
"lineAfter": 209,
"content": "\t\t\t\t\t\t\thtmlFor: fields.confirmPassword.id,"
},
{
"type": "AddedLine",
"lineAfter": 210,
"content": "\t\t\t\t\t\t\tchildren: 'Confirm Password',"
},
{
"type": "AddedLine",
"lineAfter": 211,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 212,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "AddedLine",
"lineAfter": 213,
"content": "\t\t\t\t\t\t\t...conform.input(fields.confirmPassword, { type: 'password' }),"
},
{
"type": "AddedLine",
"lineAfter": 214,
"content": "\t\t\t\t\t\t\tautoComplete: 'new-password',"
},
{
"type": "AddedLine",
"lineAfter": 215,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 216,
"content": "\t\t\t\t\t\terrors={fields.confirmPassword.errors}"
},
{
"type": "AddedLine",
"lineAfter": 217,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 218,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 219,
"content": "\t\t\t\t\t<CheckboxField"
},
{
"type": "AddedLine",
"lineAfter": 220,
"content": "\t\t\t\t\t\tlabelProps={{"
},
{
"type": "AddedLine",
"lineAfter": 221,
"content": "\t\t\t\t\t\t\thtmlFor: fields.agreeToTermsOfServiceAndPrivacyPolicy.id,"
},
{
"type": "AddedLine",
"lineAfter": 222,
"content": "\t\t\t\t\t\t\tchildren:"
},
{
"type": "AddedLine",
"lineAfter": 223,
"content": "\t\t\t\t\t\t\t\t'Do you agree to our Terms of Service and Privacy Policy?',"
},
{
"type": "AddedLine",
"lineAfter": 224,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 225,
"content": "\t\t\t\t\t\tbuttonProps={conform.input("
},
{
"type": "AddedLine",
"lineAfter": 226,
"content": "\t\t\t\t\t\t\tfields.agreeToTermsOfServiceAndPrivacyPolicy,"
},
{
"type": "AddedLine",
"lineAfter": 227,
"content": "\t\t\t\t\t\t\t{ type: 'checkbox' },"
},
{
"type": "AddedLine",
"lineAfter": 228,
"content": "\t\t\t\t\t\t)}"
},
{
"type": "AddedLine",
"lineAfter": 229,
"content": "\t\t\t\t\t\terrors={fields.agreeToTermsOfServiceAndPrivacyPolicy.errors}"
},
{
"type": "AddedLine",
"lineAfter": 230,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 231,
"content": "\t\t\t\t\t<CheckboxField"
},
{
"type": "AddedLine",
"lineAfter": 232,
"content": "\t\t\t\t\t\tlabelProps={{"
},
{
"type": "AddedLine",
"lineAfter": 233,
"content": "\t\t\t\t\t\t\thtmlFor: fields.remember.id,"
},
{
"type": "AddedLine",
"lineAfter": 234,
"content": "\t\t\t\t\t\t\tchildren: 'Remember me',"
},
{
"type": "AddedLine",
"lineAfter": 235,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 236,
"content": "\t\t\t\t\t\tbuttonProps={conform.input(fields.remember, { type: 'checkbox' })}"
},
{
"type": "AddedLine",
"lineAfter": 237,
"content": "\t\t\t\t\t\terrors={fields.remember.errors}"
},
{
"type": "AddedLine",
"lineAfter": 238,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 239,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 240,
"content": "\t\t\t\t\t<input {...conform.input(fields.redirectTo, { type: 'hidden' })} />"
},
{
"type": "AddedLine",
"lineAfter": 241,
"content": "\t\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />"
},
{
"type": "AddedLine",
"lineAfter": 242,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 243,
"content": "\t\t\t\t\t<div className=\"flex items-center justify-between gap-6\">"
},
{
"type": "AddedLine",
"lineAfter": 244,
"content": "\t\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 245,
"content": "\t\t\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "AddedLine",
"lineAfter": 246,
"content": "\t\t\t\t\t\t\tstatus={isPending ? 'pending' : actionData?.status ?? 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 247,
"content": "\t\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 248,
"content": "\t\t\t\t\t\t\tdisabled={isPending}"
},
{
"type": "AddedLine",
"lineAfter": 249,
"content": "\t\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 250,
"content": "\t\t\t\t\t\t\tCreate an account"
},
{
"type": "AddedLine",
"lineAfter": 251,
"content": "\t\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 252,
"content": "\t\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 253,
"content": "\t\t\t\t</Form>"
},
{
"type": "AddedLine",
"lineAfter": 254,
"content": "\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 255,
"content": "\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 256,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 257,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/onboarding.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 275
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { conform, useForm } from '@conform-to/react'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { getFieldsetConstraint, parse } from '@conform-to/zod'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "\tjson,"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "\tredirect,"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\ttype DataFunctionArgs,"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\ttype V2_MetaFunction,"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "} from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "\tForm,"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "\tuseActionData,"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\tuseLoaderData,"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\tuseSearchParams,"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "} from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "import { safeRedirect } from 'remix-utils'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "import { CheckboxField, ErrorList, Field } from '~/components/forms.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "import { Spacer } from '~/components/spacer.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "\tauthenticator,"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "\trequireAnonymous,"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "\tsessionKey,"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "\tsignupWithGitHub,"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "} from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "import { invariant, useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "import { sessionStorage } from '~/utils/session.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "import { nameSchema, usernameSchema } from '~/utils/user-validation.ts'"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "import { verifySessionStorage } from '~/utils/verification.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "import { checkboxSchema } from '~/utils/zod-extensions.ts'"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "import { type VerifyFunctionArgs } from './verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "export const onboardingEmailSessionKey = 'onboardingEmail'"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "export const githubIdKey = 'ghProfileId'"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "export const prefilledProfileKey = 'prefilledProfile'"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "const SignupFormSchema = z.object({"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\timageUrl: z.string().optional(),"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\tusername: usernameSchema,"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\tname: nameSchema,"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\tagreeToTermsOfServiceAndPrivacyPolicy: checkboxSchema("
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\t'You must agree to the terms of service and privacy policy',"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t),"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\tremember: checkboxSchema(),"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\tredirectTo: z.string().optional(),"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "})"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "async function requireOnboardingEmailAndGitHubId(request: Request) {"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\tawait requireAnonymous(request)"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\tconst email = verifySession.get(onboardingEmailSessionKey)"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\tconst gitHubId = verifySession.get(githubIdKey)"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\tconst result = z"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\t.object({ email: z.string(), gitHubId: z.string() })"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t\t.safeParse({ email, gitHubId: gitHubId })"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\tif (result.success) {"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t\treturn result.data"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\tthrow redirect('/signup')"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\tconst { email } = await requireOnboardingEmailAndGitHubId(request)"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\tconst cookieSession = await sessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\tconst prefilledProfile = verifySession.get(prefilledProfileKey)"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\tconst formError = cookieSession.get(authenticator.sessionErrorKey)"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\treturn json({"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\t\temail,"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "\t\tformError: typeof formError === 'string' ? formError : null,"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\t\tstatus: 'idle',"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\tsubmission: {"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t\t\tintent: '',"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\t\t\tpayload: (prefilledProfile ?? {}) as {},"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "\t\t\terror: {"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\t\t\t\t'': typeof formError === 'string' ? [formError] : [],"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\tconst { email, gitHubId } = await requireOnboardingEmailAndGitHubId(request)"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\tconst formData = await request.formData()"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\tconst submission = await parse(formData, {"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "\t\tschema: SignupFormSchema.superRefine(async (data, ctx) => {"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\t\t\tconst existingUser = await prisma.user.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t\t\t\twhere: { username: data.username },"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\t\t\t\tselect: { id: true },"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": "\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "\t\t\tif (existingUser) {"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": "\t\t\t\tctx.addIssue({"
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "\t\t\t\t\tpath: ['username'],"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\t\t\t\t\tcode: z.ZodIssueCode.custom,"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "\t\t\t\t\tmessage: 'A user already exists with this username',"
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": "\t\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "\t\t\t\treturn"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "\t\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": "\t\t}).transform(async data => {"
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "\t\t\tconst session = await signupWithGitHub({"
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": "\t\t\t\t...data,"
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": "\t\t\t\temail,"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "\t\t\t\tgitHubId: gitHubId,"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": "\t\t\treturn { ...data, session }"
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\t\tasync: true,"
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\tif (submission.intent !== 'submit') {"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\t\treturn json({ status: 'idle', submission } as const)"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\tif (!submission.value?.session) {"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\tconst { session, remember, redirectTo } = submission.value"
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\tconst cookieSession = await sessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\tcookieSession.set(sessionKey, session.id)"
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": "\tconst headers = new Headers()"
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\theaders.append("
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "\t\t'set-cookie',"
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": "\t\tawait sessionStorage.commitSession(cookieSession, {"
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "\t\t\texpires: remember ? session.expirationDate : undefined,"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\theaders.append("
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "\t\t'set-cookie',"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": "\t\tawait verifySessionStorage.destroySession(verifySession),"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\treturn redirect(safeRedirect(redirectTo), { headers })"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "export async function handleVerification({"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "\tsubmission,"
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": "}: VerifyFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "\tinvariant(submission.value, 'submission.value should be defined by now')"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": "\tverifySession.set(onboardingEmailSessionKey, submission.value.target)"
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": "\treturn redirect('/onboarding', {"
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": "\t\theaders: {"
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": "\t\t\t'set-cookie': await verifySessionStorage.commitSession(verifySession),"
},
{
"type": "AddedLine",
"lineAfter": 163,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 164,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 165,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 166,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": "export const meta: V2_MetaFunction = () => {"
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": "\treturn [{ title: 'Setup Epic Notes Account' }]"
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "export default function SignupRoute() {"
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "\tconst data = useLoaderData<typeof loader>()"
},
{
"type": "AddedLine",
"lineAfter": 173,
"content": "\tconst actionData = useActionData<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 174,
"content": "\tconst isPending = useIsPending()"
},
{
"type": "AddedLine",
"lineAfter": 175,
"content": "\tconst [searchParams] = useSearchParams()"
},
{
"type": "AddedLine",
"lineAfter": 176,
"content": "\tconst redirectTo = searchParams.get('redirectTo')"
},
{
"type": "AddedLine",
"lineAfter": 177,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 178,
"content": "\tconst [form, fields] = useForm({"
},
{
"type": "AddedLine",
"lineAfter": 179,
"content": "\t\tid: 'signup-form',"
},
{
"type": "AddedLine",
"lineAfter": 180,
"content": "\t\tconstraint: getFieldsetConstraint(SignupFormSchema),"
},
{
"type": "AddedLine",
"lineAfter": 181,
"content": "\t\tlastSubmission: actionData?.submission ?? data.submission,"
},
{
"type": "AddedLine",
"lineAfter": 182,
"content": "\t\tonValidate({ formData }) {"
},
{
"type": "AddedLine",
"lineAfter": 183,
"content": "\t\t\treturn parse(formData, { schema: SignupFormSchema })"
},
{
"type": "AddedLine",
"lineAfter": 184,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 185,
"content": "\t\tshouldRevalidate: 'onBlur',"
},
{
"type": "AddedLine",
"lineAfter": 186,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 187,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 188,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 189,
"content": "\t\t<div className=\"container flex min-h-full flex-col justify-center pb-32 pt-20\">"
},
{
"type": "AddedLine",
"lineAfter": 190,
"content": "\t\t\t<div className=\"mx-auto w-full max-w-lg\">"
},
{
"type": "AddedLine",
"lineAfter": 191,
"content": "\t\t\t\t<div className=\"flex flex-col gap-3 text-center\">"
},
{
"type": "AddedLine",
"lineAfter": 192,
"content": "\t\t\t\t\t<h1 className=\"text-h1\">Welcome aboard {data.email}!</h1>"
},
{
"type": "AddedLine",
"lineAfter": 193,
"content": "\t\t\t\t\t<p className=\"text-body-md text-muted-foreground\">"
},
{
"type": "AddedLine",
"lineAfter": 194,
"content": "\t\t\t\t\t\tPlease enter your details."
},
{
"type": "AddedLine",
"lineAfter": 195,
"content": "\t\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 196,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 197,
"content": "\t\t\t\t<Spacer size=\"xs\" />"
},
{
"type": "AddedLine",
"lineAfter": 198,
"content": "\t\t\t\t<Form"
},
{
"type": "AddedLine",
"lineAfter": 199,
"content": "\t\t\t\t\tmethod=\"POST\""
},
{
"type": "AddedLine",
"lineAfter": 200,
"content": "\t\t\t\t\tclassName=\"mx-auto min-w-[368px] max-w-sm\""
},
{
"type": "AddedLine",
"lineAfter": 201,
"content": "\t\t\t\t\t{...form.props}"
},
{
"type": "AddedLine",
"lineAfter": 202,
"content": "\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 203,
"content": "\t\t\t\t\t{fields.imageUrl.defaultValue ? ("
},
{
"type": "AddedLine",
"lineAfter": 204,
"content": "\t\t\t\t\t\t<div className=\"flex justify-center gap-4 items-center flex-col mb-4\">"
},
{
"type": "AddedLine",
"lineAfter": 205,
"content": "\t\t\t\t\t\t\t<img"
},
{
"type": "AddedLine",
"lineAfter": 206,
"content": "\t\t\t\t\t\t\t\tsrc={fields.imageUrl.defaultValue}"
},
{
"type": "AddedLine",
"lineAfter": 207,
"content": "\t\t\t\t\t\t\t\talt=\"Profile\""
},
{
"type": "AddedLine",
"lineAfter": 208,
"content": "\t\t\t\t\t\t\t\tclassName=\"rounded-full w-24 h-24\""
},
{
"type": "AddedLine",
"lineAfter": 209,
"content": "\t\t\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 210,
"content": "\t\t\t\t\t\t\t<p className=\"text-body-sm text-muted-foreground\">"
},
{
"type": "AddedLine",
"lineAfter": 211,
"content": "\t\t\t\t\t\t\t\tYou can change your photo later"
},
{
"type": "AddedLine",
"lineAfter": 212,
"content": "\t\t\t\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 213,
"content": "\t\t\t\t\t\t\t<input {...conform.input(fields.imageUrl, { type: 'hidden' })} />"
},
{
"type": "AddedLine",
"lineAfter": 214,
"content": "\t\t\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 215,
"content": "\t\t\t\t\t) : null}"
},
{
"type": "AddedLine",
"lineAfter": 216,
"content": "\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 217,
"content": "\t\t\t\t\t\tlabelProps={{ htmlFor: fields.username.id, children: 'Username' }}"
},
{
"type": "AddedLine",
"lineAfter": 218,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "AddedLine",
"lineAfter": 219,
"content": "\t\t\t\t\t\t\t...conform.input(fields.username),"
},
{
"type": "AddedLine",
"lineAfter": 220,
"content": "\t\t\t\t\t\t\tautoComplete: 'username',"
},
{
"type": "AddedLine",
"lineAfter": 221,
"content": "\t\t\t\t\t\t\tclassName: 'lowercase',"
},
{
"type": "AddedLine",
"lineAfter": 222,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 223,
"content": "\t\t\t\t\t\terrors={fields.username.errors}"
},
{
"type": "AddedLine",
"lineAfter": 224,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 225,
"content": "\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 226,
"content": "\t\t\t\t\t\tlabelProps={{ htmlFor: fields.name.id, children: 'Name' }}"
},
{
"type": "AddedLine",
"lineAfter": 227,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "AddedLine",
"lineAfter": 228,
"content": "\t\t\t\t\t\t\t...conform.input(fields.name),"
},
{
"type": "AddedLine",
"lineAfter": 229,
"content": "\t\t\t\t\t\t\tautoComplete: 'name',"
},
{
"type": "AddedLine",
"lineAfter": 230,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 231,
"content": "\t\t\t\t\t\terrors={fields.name.errors}"
},
{
"type": "AddedLine",
"lineAfter": 232,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 233,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 234,
"content": "\t\t\t\t\t<CheckboxField"
},
{
"type": "AddedLine",
"lineAfter": 235,
"content": "\t\t\t\t\t\tlabelProps={{"
},
{
"type": "AddedLine",
"lineAfter": 236,
"content": "\t\t\t\t\t\t\thtmlFor: fields.agreeToTermsOfServiceAndPrivacyPolicy.id,"
},
{
"type": "AddedLine",
"lineAfter": 237,
"content": "\t\t\t\t\t\t\tchildren:"
},
{
"type": "AddedLine",
"lineAfter": 238,
"content": "\t\t\t\t\t\t\t\t'Do you agree to our Terms of Service and Privacy Policy?',"
},
{
"type": "AddedLine",
"lineAfter": 239,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 240,
"content": "\t\t\t\t\t\tbuttonProps={conform.input("
},
{
"type": "AddedLine",
"lineAfter": 241,
"content": "\t\t\t\t\t\t\tfields.agreeToTermsOfServiceAndPrivacyPolicy,"
},
{
"type": "AddedLine",
"lineAfter": 242,
"content": "\t\t\t\t\t\t\t{ type: 'checkbox' },"
},
{
"type": "AddedLine",
"lineAfter": 243,
"content": "\t\t\t\t\t\t)}"
},
{
"type": "AddedLine",
"lineAfter": 244,
"content": "\t\t\t\t\t\terrors={fields.agreeToTermsOfServiceAndPrivacyPolicy.errors}"
},
{
"type": "AddedLine",
"lineAfter": 245,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 246,
"content": "\t\t\t\t\t<CheckboxField"
},
{
"type": "AddedLine",
"lineAfter": 247,
"content": "\t\t\t\t\t\tlabelProps={{"
},
{
"type": "AddedLine",
"lineAfter": 248,
"content": "\t\t\t\t\t\t\thtmlFor: fields.remember.id,"
},
{
"type": "AddedLine",
"lineAfter": 249,
"content": "\t\t\t\t\t\t\tchildren: 'Remember me',"
},
{
"type": "AddedLine",
"lineAfter": 250,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 251,
"content": "\t\t\t\t\t\tbuttonProps={conform.input(fields.remember, { type: 'checkbox' })}"
},
{
"type": "AddedLine",
"lineAfter": 252,
"content": "\t\t\t\t\t\terrors={fields.remember.errors}"
},
{
"type": "AddedLine",
"lineAfter": 253,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 254,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 255,
"content": "\t\t\t\t\t{redirectTo ? ("
},
{
"type": "AddedLine",
"lineAfter": 256,
"content": "\t\t\t\t\t\t<input type=\"hidden\" name=\"redirectTo\" value={redirectTo} />"
},
{
"type": "AddedLine",
"lineAfter": 257,
"content": "\t\t\t\t\t) : null}"
},
{
"type": "AddedLine",
"lineAfter": 258,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 259,
"content": "\t\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />"
},
{
"type": "AddedLine",
"lineAfter": 260,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 261,
"content": "\t\t\t\t\t<div className=\"flex items-center justify-between gap-6\">"
},
{
"type": "AddedLine",
"lineAfter": 262,
"content": "\t\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 263,
"content": "\t\t\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "AddedLine",
"lineAfter": 264,
"content": "\t\t\t\t\t\t\tstatus={isPending ? 'pending' : actionData?.status ?? 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 265,
"content": "\t\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 266,
"content": "\t\t\t\t\t\t\tdisabled={isPending}"
},
{
"type": "AddedLine",
"lineAfter": 267,
"content": "\t\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 268,
"content": "\t\t\t\t\t\t\tCreate an account"
},
{
"type": "AddedLine",
"lineAfter": 269,
"content": "\t\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 270,
"content": "\t\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 271,
"content": "\t\t\t\t</Form>"
},
{
"type": "AddedLine",
"lineAfter": 272,
"content": "\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 273,
"content": "\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 274,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 275,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/onboarding_.github.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 184
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { conform, useForm } from '@conform-to/react'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { getFieldsetConstraint, parse } from '@conform-to/zod'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "\tjson,"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "\tredirect,"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\ttype DataFunctionArgs,"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\ttype V2_MetaFunction,"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "} from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import { Form, useActionData, useLoaderData } from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "import { ErrorList, Field } from '~/components/forms.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "\tlogout,"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "\trequireAnonymous,"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "\tresetUserPassword,"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "} from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "import { invariant, useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "import { passwordSchema } from '~/utils/user-validation.ts'"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "import { verifySessionStorage } from '~/utils/verification.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "import { type VerifyFunctionArgs } from './verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "const resetPasswordUsernameSessionKey = 'resetPasswordUsername'"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "export async function handleVerification({"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\tsubmission,"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "}: VerifyFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\tinvariant(submission.value, 'submission.value should be defined by now')"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "\tconst target = submission.value.target"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "\tconst user = await prisma.user.findFirst({"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\t\twhere: { OR: [{ email: target }, { username: target }] },"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\t\tselect: { email: true, username: true },"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t// we don't want to say the user is not found if the email is not found"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t// because that would allow an attacker to check if an email is registered"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\tif (!user) {"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t\tsubmission.error.code = 'Invalid code'"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\tverifySession.set(resetPasswordUsernameSessionKey, user.username)"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\treturn redirect('/reset-password', {"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\t\theaders: {"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\t\t\t'set-cookie': await verifySessionStorage.commitSession(verifySession),"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "const ResetPasswordSchema = z"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t.object({"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\tpassword: passwordSchema,"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t\tconfirmPassword: passwordSchema,"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t.refine(({ confirmPassword, password }) => password === confirmPassword, {"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t\tmessage: 'The passwords did not match',"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\t\tpath: ['confirmPassword'],"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "async function requireResetPasswordUsername(request: Request) {"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\tawait requireAnonymous(request)"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\tconst resetPasswordUsername = verifySession.get("
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\t\tresetPasswordUsernameSessionKey,"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\tif (typeof resetPasswordUsername !== 'string' || !resetPasswordUsername) {"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\t\tthrow redirect('/login')"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "\treturn resetPasswordUsername"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\tconst resetPasswordUsername = await requireResetPasswordUsername(request)"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\treturn json({ resetPasswordUsername })"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\tconst resetPasswordUsername = await requireResetPasswordUsername(request)"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\tconst formData = await request.formData()"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\tconst submission = parse(formData, {"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\t\tschema: ResetPasswordSchema,"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "\t\tacceptMultipleErrors: () => true,"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\tif (submission.intent !== 'submit') {"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\t\treturn json({ status: 'idle', submission } as const)"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\tif (!submission.value?.password) {"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\tconst { password } = submission.value"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "\tawait resetUserPassword({ username: resetPasswordUsername, password })"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": "\tthrow await logout("
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "\t\t{ request, redirectTo: '/login' },"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": "\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "\t\t\theaders: {"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\t\t\t\t'set-cookie': await verifySessionStorage.destroySession(verifySession),"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "export const meta: V2_MetaFunction = () => {"
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": "\treturn [{ title: 'Reset Password | Epic Notes' }]"
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "export default function ResetPasswordPage() {"
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": "\tconst data = useLoaderData<typeof loader>()"
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\tconst actionData = useActionData<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\tconst isPending = useIsPending()"
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "\tconst [form, fields] = useForm({"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\t\tid: 'reset-password',"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\t\tconstraint: getFieldsetConstraint(ResetPasswordSchema),"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t\tlastSubmission: actionData?.submission,"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t\tonValidate({ formData }) {"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": "\t\t\treturn parse(formData, { schema: ResetPasswordSchema })"
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "\t\tshouldRevalidate: 'onBlur',"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": "\t\t<div className=\"container flex flex-col justify-center pb-32 pt-20\">"
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\t\t\t<div className=\"text-center\">"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\t\t\t\t<h1 className=\"text-h1\">Password Reset</h1>"
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": "\t\t\t\t<p className=\"mt-3 text-body-md text-muted-foreground\">"
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\t\t\t\t\tHi, {data.resetPasswordUsername}. No worries. It happens all the time."
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": "\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "\t\t\t<div className=\"mx-auto mt-16 min-w-[368px] max-w-sm\">"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\t\t\t\t<Form method=\"POST\" {...form.props}>"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\t\t\t\t\t\tlabelProps={{"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "\t\t\t\t\t\t\thtmlFor: fields.password.id,"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": "\t\t\t\t\t\t\tchildren: 'New Password',"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\t\t\t\t\t\t\t...conform.input(fields.password, { type: 'password' }),"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "\t\t\t\t\t\t\tautoComplete: 'new-password',"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "\t\t\t\t\t\t\tautoFocus: true,"
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "\t\t\t\t\t\terrors={fields.password.errors}"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": "\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "\t\t\t\t\t\tlabelProps={{"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "\t\t\t\t\t\t\thtmlFor: fields.confirmPassword.id,"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\t\t\t\t\t\t\tchildren: 'Confirm Password',"
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": "\t\t\t\t\t\t\t...conform.input(fields.confirmPassword, { type: 'password' }),"
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": "\t\t\t\t\t\t\tautoComplete: 'new-password',"
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 163,
"content": "\t\t\t\t\t\terrors={fields.confirmPassword.errors}"
},
{
"type": "AddedLine",
"lineAfter": 164,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 165,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 166,
"content": "\t\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />"
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": "\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": "\t\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": "\t\t\t\t\t\tstatus={isPending ? 'pending' : actionData?.status ?? 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "\t\t\t\t\t\tdisabled={isPending}"
},
{
"type": "AddedLine",
"lineAfter": 173,
"content": "\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 174,
"content": "\t\t\t\t\t\tReset password"
},
{
"type": "AddedLine",
"lineAfter": 175,
"content": "\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 176,
"content": "\t\t\t\t</Form>"
},
{
"type": "AddedLine",
"lineAfter": 177,
"content": "\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 178,
"content": "\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 179,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 180,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 181,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 182,
"content": "export function ErrorBoundary() {"
},
{
"type": "AddedLine",
"lineAfter": 183,
"content": "\treturn <GeneralErrorBoundary />"
},
{
"type": "AddedLine",
"lineAfter": 184,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/reset-password.tsx"
},
{
"type": "ChangedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 6
},
"fromFileRange": {
"start": 1,
"lines": 5
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 1,
"lineAfter": 1,
"content": "import { conform, useForm } from '@conform-to/react'"
},
{
"type": "UnchangedLine",
"lineBefore": 2,
"lineAfter": 2,
"content": "import { getFieldsetConstraint, parse } from '@conform-to/zod'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import * as E from '@react-email/components'"
},
{
"type": "UnchangedLine",
"lineBefore": 3,
"lineAfter": 4,
"content": "import {"
},
{
"type": "UnchangedLine",
"lineBefore": 4,
"lineAfter": 5,
"content": "\tjson,"
},
{
"type": "UnchangedLine",
"lineBefore": 5,
"lineAfter": 6,
"content": "\tredirect,"
}
]
},
{
"context": "import {",
"type": "Chunk",
"toFileRange": {
"start": 8,
"lines": 164
},
"fromFileRange": {
"start": 7,
"lines": 217
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 7,
"lineAfter": 8,
"content": "\ttype V2_MetaFunction,"
},
{
"type": "UnchangedLine",
"lineBefore": 8,
"lineAfter": 9,
"content": "} from '@remix-run/node'"
},
{
"type": "UnchangedLine",
"lineBefore": 9,
"lineAfter": 10,
"content": "import { Form, useActionData, useSearchParams } from '@remix-run/react'"
},
{
"type": "DeletedLine",
"lineBefore": 10,
"content": "import { safeRedirect } from 'remix-utils'"
},
{
"type": "UnchangedLine",
"lineBefore": 11,
"lineAfter": 11,
"content": "import { z } from 'zod'"
},
{
"type": "DeletedLine",
"lineBefore": 12,
"content": "import { CheckboxField, ErrorList, Field } from '~/components/forms.tsx'"
},
{
"type": "DeletedLine",
"lineBefore": 13,
"content": "import { Spacer } from '~/components/spacer.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "import { ErrorList, Field } from '~/components/forms.tsx'"
},
{
"type": "UnchangedLine",
"lineBefore": 14,
"lineAfter": 14,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "DeletedLine",
"lineBefore": 15,
"content": "import { requireAnonymous, sessionKey, signup } from '~/utils/auth.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 16,
"lineAfter": 15,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "import { sendEmail } from '~/utils/email.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 17,
"lineAfter": 17,
"content": "import { useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "DeletedLine",
"lineBefore": 18,
"content": "import { commitSession, getSession } from '~/utils/session.server.ts'"
},
{
"type": "DeletedLine",
"lineBefore": 19,
"content": "import {"
},
{
"type": "DeletedLine",
"lineBefore": 20,
"content": "\temailSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 21,
"content": "\tnameSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 22,
"content": "\tpasswordSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 23,
"content": "\tusernameSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 24,
"content": "} from '~/utils/user-validation.ts'"
},
{
"type": "DeletedLine",
"lineBefore": 25,
"content": "import { checkboxSchema } from '~/utils/zod-extensions.ts'"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "import { emailSchema } from '~/utils/user-validation.ts'"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "import { prepareVerification } from './verify.tsx'"
},
{
"type": "UnchangedLine",
"lineBefore": 26,
"lineAfter": 20,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 27,
"content": "const SignupFormSchema = z"
},
{
"type": "DeletedLine",
"lineBefore": 28,
"content": "\t.object({"
},
{
"type": "DeletedLine",
"lineBefore": 29,
"content": "\t\tusername: usernameSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 30,
"content": "\t\tname: nameSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 31,
"content": "\t\temail: emailSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 32,
"content": "\t\tpassword: passwordSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 33,
"content": "\t\tconfirmPassword: passwordSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 34,
"content": "\t\tagreeToTermsOfServiceAndPrivacyPolicy: checkboxSchema("
},
{
"type": "DeletedLine",
"lineBefore": 35,
"content": "\t\t\t'You must agree to the terms of service and privacy policy',"
},
{
"type": "DeletedLine",
"lineBefore": 36,
"content": "\t\t),"
},
{
"type": "DeletedLine",
"lineBefore": 37,
"content": "\t\tremember: checkboxSchema(),"
},
{
"type": "DeletedLine",
"lineBefore": 38,
"content": "\t\tredirectTo: z.string().optional(),"
},
{
"type": "DeletedLine",
"lineBefore": 39,
"content": "\t})"
},
{
"type": "DeletedLine",
"lineBefore": 40,
"content": "\t.superRefine(({ confirmPassword, password }, ctx) => {"
},
{
"type": "DeletedLine",
"lineBefore": 41,
"content": "\t\tif (confirmPassword !== password) {"
},
{
"type": "DeletedLine",
"lineBefore": 42,
"content": "\t\t\tctx.addIssue({"
},
{
"type": "DeletedLine",
"lineBefore": 43,
"content": "\t\t\t\tpath: ['confirmPassword'],"
},
{
"type": "DeletedLine",
"lineBefore": 44,
"content": "\t\t\t\tcode: 'custom',"
},
{
"type": "DeletedLine",
"lineBefore": 45,
"content": "\t\t\t\tmessage: 'The passwords must match',"
},
{
"type": "DeletedLine",
"lineBefore": 46,
"content": "\t\t\t})"
},
{
"type": "DeletedLine",
"lineBefore": 47,
"content": "\t\t}"
},
{
"type": "DeletedLine",
"lineBefore": 48,
"content": "\t})"
},
{
"type": "DeletedLine",
"lineBefore": 49,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 50,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "DeletedLine",
"lineBefore": 51,
"content": "\tawait requireAnonymous(request)"
},
{
"type": "DeletedLine",
"lineBefore": 52,
"content": "\treturn json({})"
},
{
"type": "DeletedLine",
"lineBefore": 53,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "const SignupSchema = z.object({"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "\temail: emailSchema,"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "})"
},
{
"type": "UnchangedLine",
"lineBefore": 54,
"lineAfter": 24,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 55,
"lineAfter": 25,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "DeletedLine",
"lineBefore": 56,
"content": "\tawait requireAnonymous(request)"
},
{
"type": "UnchangedLine",
"lineBefore": 57,
"lineAfter": 26,
"content": "\tconst formData = await request.formData()"
},
{
"type": "UnchangedLine",
"lineBefore": 58,
"lineAfter": 27,
"content": "\tconst submission = await parse(formData, {"
},
{
"type": "DeletedLine",
"lineBefore": 59,
"content": "\t\tschema: SignupFormSchema.superRefine(async (data, ctx) => {"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\t\tschema: SignupSchema.superRefine(async (data, ctx) => {"
},
{
"type": "UnchangedLine",
"lineBefore": 60,
"lineAfter": 29,
"content": "\t\t\tconst existingUser = await prisma.user.findUnique({"
},
{
"type": "DeletedLine",
"lineBefore": 61,
"content": "\t\t\t\twhere: { username: data.username },"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\t\t\t\twhere: { email: data.email },"
},
{
"type": "UnchangedLine",
"lineBefore": 62,
"lineAfter": 31,
"content": "\t\t\t\tselect: { id: true },"
},
{
"type": "UnchangedLine",
"lineBefore": 63,
"lineAfter": 32,
"content": "\t\t\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 64,
"lineAfter": 33,
"content": "\t\t\tif (existingUser) {"
},
{
"type": "UnchangedLine",
"lineBefore": 65,
"lineAfter": 34,
"content": "\t\t\t\tctx.addIssue({"
},
{
"type": "DeletedLine",
"lineBefore": 66,
"content": "\t\t\t\t\tpath: ['username'],"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\t\t\t\t\tpath: ['email'],"
},
{
"type": "UnchangedLine",
"lineBefore": 67,
"lineAfter": 36,
"content": "\t\t\t\t\tcode: z.ZodIssueCode.custom,"
},
{
"type": "DeletedLine",
"lineBefore": 68,
"content": "\t\t\t\t\tmessage: 'A user already exists with this username',"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t\t\t\t\tmessage: 'A user already exists with this email',"
},
{
"type": "UnchangedLine",
"lineBefore": 69,
"lineAfter": 38,
"content": "\t\t\t\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 70,
"lineAfter": 39,
"content": "\t\t\t\treturn"
},
{
"type": "UnchangedLine",
"lineBefore": 71,
"lineAfter": 40,
"content": "\t\t\t}"
},
{
"type": "DeletedLine",
"lineBefore": 72,
"content": "\t\t}).transform(async data => {"
},
{
"type": "DeletedLine",
"lineBefore": 73,
"content": "\t\t\tconst session = await signup(data)"
},
{
"type": "DeletedLine",
"lineBefore": 74,
"content": "\t\t\treturn { ...data, session }"
},
{
"type": "UnchangedLine",
"lineBefore": 75,
"lineAfter": 41,
"content": "\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t\tacceptMultipleErrors: () => true,"
},
{
"type": "UnchangedLine",
"lineBefore": 76,
"lineAfter": 43,
"content": "\t\tasync: true,"
},
{
"type": "UnchangedLine",
"lineBefore": 77,
"lineAfter": 44,
"content": "\t})"
},
{
"type": "DeletedLine",
"lineBefore": 78,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 79,
"lineAfter": 45,
"content": "\tif (submission.intent !== 'submit') {"
},
{
"type": "UnchangedLine",
"lineBefore": 80,
"lineAfter": 46,
"content": "\t\treturn json({ status: 'idle', submission } as const)"
},
{
"type": "UnchangedLine",
"lineBefore": 81,
"lineAfter": 47,
"content": "\t}"
},
{
"type": "DeletedLine",
"lineBefore": 82,
"content": "\tif (!submission.value?.session) {"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\tif (!submission.value) {"
},
{
"type": "UnchangedLine",
"lineBefore": 83,
"lineAfter": 49,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "UnchangedLine",
"lineBefore": 84,
"lineAfter": 50,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\tconst { email } = submission.value"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\tconst { verifyUrl, redirectTo, otp } = await prepareVerification({"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t\tperiod: 10 * 60,"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\t\ttype: 'onboarding',"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t\ttarget: email,"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 85,
"lineAfter": 58,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 86,
"content": "\tconst { session, remember, redirectTo } = submission.value"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\tconst response = await sendEmail({"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t\tto: email,"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t\tsubject: `Welcome to Epic Notes!`,"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\t\treact: <SignupEmail onboardingUrl={verifyUrl.toString()} otp={otp} />,"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 87,
"lineAfter": 64,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 88,
"content": "\tconst cookieSession = await getSession(request.headers.get('cookie'))"
},
{
"type": "DeletedLine",
"lineBefore": 89,
"content": "\tcookieSession.set(sessionKey, session.id)"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\tif (response.status === 'success') {"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\t\treturn redirect(redirectTo.toString())"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\t} else {"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\t\tsubmission.error[''] = response.error.message"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 500 })"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 90,
"lineAfter": 72,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 91,
"content": "\treturn redirect(safeRedirect(redirectTo), {"
},
{
"type": "DeletedLine",
"lineBefore": 92,
"content": "\t\theaders: {"
},
{
"type": "DeletedLine",
"lineBefore": 93,
"content": "\t\t\t'set-cookie': await commitSession(cookieSession, {"
},
{
"type": "DeletedLine",
"lineBefore": 94,
"content": "\t\t\t\t// Cookies with no expiration are cleared when the tab/window closes"
},
{
"type": "DeletedLine",
"lineBefore": 95,
"content": "\t\t\t\texpires: remember ? session.expirationDate : undefined,"
},
{
"type": "DeletedLine",
"lineBefore": 96,
"content": "\t\t\t}),"
},
{
"type": "DeletedLine",
"lineBefore": 97,
"content": "\t\t},"
},
{
"type": "DeletedLine",
"lineBefore": 98,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "export function SignupEmail({"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\tonboardingUrl,"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\totp,"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "}: {"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\tonboardingUrl: string"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\totp: string"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "}) {"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\t<E.Html lang=\"en\" dir=\"ltr\">"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t\t\t<E.Container>"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\t\t\t\t<h1>"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "\t\t\t\t\t<E.Text>Welcome to Epic Notes!</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\t\t\t\t</h1>"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\t\t\t\t\t<E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\t\t\t\t\t\tHere's your verification code: <strong>{otp}</strong>"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "\t\t\t\t\t</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\t\t\t\t\t<E.Text>Or click the link to get started:</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\t\t\t\t<E.Link href={onboardingUrl}>{onboardingUrl}</E.Link>"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t\t\t</E.Container>"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "\t\t</E.Html>"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 99,
"lineAfter": 98,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 100,
"lineAfter": 99,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 101,
"lineAfter": 100,
"content": "export const meta: V2_MetaFunction = () => {"
},
{
"type": "DeletedLine",
"lineBefore": 102,
"content": "\treturn [{ title: 'Setup Epic Notes Account' }]"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\treturn [{ title: 'Sign Up | Epic Notes' }]"
},
{
"type": "UnchangedLine",
"lineBefore": 103,
"lineAfter": 102,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 104,
"lineAfter": 103,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 105,
"lineAfter": 104,
"content": "export default function SignupRoute() {"
},
{
"type": "UnchangedLine",
"lineBefore": 106,
"lineAfter": 105,
"content": "\tconst actionData = useActionData<typeof action>()"
},
{
"type": "UnchangedLine",
"lineBefore": 107,
"lineAfter": 106,
"content": "\tconst isPending = useIsPending()"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\tconst isGitHubSubmitting = useIsPending({ formAction: '/auth/github' })"
},
{
"type": "UnchangedLine",
"lineBefore": 108,
"lineAfter": 108,
"content": "\tconst [searchParams] = useSearchParams()"
},
{
"type": "UnchangedLine",
"lineBefore": 109,
"lineAfter": 109,
"content": "\tconst redirectTo = searchParams.get('redirectTo')"
},
{
"type": "UnchangedLine",
"lineBefore": 110,
"lineAfter": 110,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 111,
"lineAfter": 111,
"content": "\tconst [form, fields] = useForm({"
},
{
"type": "UnchangedLine",
"lineBefore": 112,
"lineAfter": 112,
"content": "\t\tid: 'signup-form',"
},
{
"type": "DeletedLine",
"lineBefore": 113,
"content": "\t\tconstraint: getFieldsetConstraint(SignupFormSchema),"
},
{
"type": "DeletedLine",
"lineBefore": 114,
"content": "\t\tdefaultValue: { redirectTo },"
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "\t\tconstraint: getFieldsetConstraint(SignupSchema),"
},
{
"type": "UnchangedLine",
"lineBefore": 115,
"lineAfter": 114,
"content": "\t\tlastSubmission: actionData?.submission,"
},
{
"type": "UnchangedLine",
"lineBefore": 116,
"lineAfter": 115,
"content": "\t\tonValidate({ formData }) {"
},
{
"type": "DeletedLine",
"lineBefore": 117,
"content": "\t\t\treturn parse(formData, { schema: SignupFormSchema })"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "\t\t\tconst result = parse(formData, { schema: SignupSchema })"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "\t\t\treturn result"
},
{
"type": "UnchangedLine",
"lineBefore": 118,
"lineAfter": 118,
"content": "\t\t},"
},
{
"type": "UnchangedLine",
"lineBefore": 119,
"lineAfter": 119,
"content": "\t\tshouldRevalidate: 'onBlur',"
},
{
"type": "UnchangedLine",
"lineBefore": 120,
"lineAfter": 120,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 121,
"lineAfter": 121,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 122,
"lineAfter": 122,
"content": "\treturn ("
},
{
"type": "DeletedLine",
"lineBefore": 123,
"content": "\t\t<div className=\"container flex min-h-full flex-col justify-center pb-32 pt-20\">"
},
{
"type": "DeletedLine",
"lineBefore": 124,
"content": "\t\t\t<div className=\"mx-auto w-full max-w-lg\">"
},
{
"type": "DeletedLine",
"lineBefore": 125,
"content": "\t\t\t\t<div className=\"flex flex-col gap-3 text-center\">"
},
{
"type": "DeletedLine",
"lineBefore": 126,
"content": "\t\t\t\t\t<h1 className=\"text-h1\">Welcome aboard!</h1>"
},
{
"type": "DeletedLine",
"lineBefore": 127,
"content": "\t\t\t\t\t<p className=\"text-body-md text-muted-foreground\">"
},
{
"type": "DeletedLine",
"lineBefore": 128,
"content": "\t\t\t\t\t\tPlease enter your details."
},
{
"type": "DeletedLine",
"lineBefore": 129,
"content": "\t\t\t\t\t</p>"
},
{
"type": "DeletedLine",
"lineBefore": 130,
"content": "\t\t\t\t</div>"
},
{
"type": "DeletedLine",
"lineBefore": 131,
"content": "\t\t\t\t<Spacer size=\"xs\" />"
},
{
"type": "DeletedLine",
"lineBefore": 132,
"content": "\t\t\t\t<Form"
},
{
"type": "DeletedLine",
"lineBefore": 133,
"content": "\t\t\t\t\tmethod=\"POST\""
},
{
"type": "DeletedLine",
"lineBefore": 134,
"content": "\t\t\t\t\tclassName=\"mx-auto min-w-[368px] max-w-sm\""
},
{
"type": "DeletedLine",
"lineBefore": 135,
"content": "\t\t\t\t\t{...form.props}"
},
{
"type": "DeletedLine",
"lineBefore": 136,
"content": "\t\t\t\t>"
},
{
"type": "DeletedLine",
"lineBefore": 137,
"content": "\t\t\t\t\t<Field"
},
{
"type": "DeletedLine",
"lineBefore": 138,
"content": "\t\t\t\t\t\tlabelProps={{ htmlFor: fields.email.id, children: 'Email' }}"
},
{
"type": "DeletedLine",
"lineBefore": 139,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "DeletedLine",
"lineBefore": 140,
"content": "\t\t\t\t\t\t\t...conform.input(fields.email),"
},
{
"type": "DeletedLine",
"lineBefore": 141,
"content": "\t\t\t\t\t\t\tautoComplete: 'email',"
},
{
"type": "DeletedLine",
"lineBefore": 142,
"content": "\t\t\t\t\t\t\tautoFocus: true,"
},
{
"type": "DeletedLine",
"lineBefore": 143,
"content": "\t\t\t\t\t\t\tclassName: 'lowercase',"
},
{
"type": "DeletedLine",
"lineBefore": 144,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "DeletedLine",
"lineBefore": 145,
"content": "\t\t\t\t\t\terrors={fields.email.errors}"
},
{
"type": "DeletedLine",
"lineBefore": 146,
"content": "\t\t\t\t\t/>"
},
{
"type": "DeletedLine",
"lineBefore": 147,
"content": "\t\t\t\t\t<Field"
},
{
"type": "DeletedLine",
"lineBefore": 148,
"content": "\t\t\t\t\t\tlabelProps={{ htmlFor: fields.username.id, children: 'Username' }}"
},
{
"type": "DeletedLine",
"lineBefore": 149,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "DeletedLine",
"lineBefore": 150,
"content": "\t\t\t\t\t\t\t...conform.input(fields.username),"
},
{
"type": "DeletedLine",
"lineBefore": 151,
"content": "\t\t\t\t\t\t\tautoComplete: 'username',"
},
{
"type": "DeletedLine",
"lineBefore": 152,
"content": "\t\t\t\t\t\t\tclassName: 'lowercase',"
},
{
"type": "DeletedLine",
"lineBefore": 153,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "DeletedLine",
"lineBefore": 154,
"content": "\t\t\t\t\t\terrors={fields.username.errors}"
},
{
"type": "DeletedLine",
"lineBefore": 155,
"content": "\t\t\t\t\t/>"
},
{
"type": "DeletedLine",
"lineBefore": 156,
"content": "\t\t\t\t\t<Field"
},
{
"type": "DeletedLine",
"lineBefore": 157,
"content": "\t\t\t\t\t\tlabelProps={{ htmlFor: fields.name.id, children: 'Name' }}"
},
{
"type": "DeletedLine",
"lineBefore": 158,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "DeletedLine",
"lineBefore": 159,
"content": "\t\t\t\t\t\t\t...conform.input(fields.name),"
},
{
"type": "DeletedLine",
"lineBefore": 160,
"content": "\t\t\t\t\t\t\tautoComplete: 'name',"
},
{
"type": "DeletedLine",
"lineBefore": 161,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "DeletedLine",
"lineBefore": 162,
"content": "\t\t\t\t\t\terrors={fields.name.errors}"
},
{
"type": "DeletedLine",
"lineBefore": 163,
"content": "\t\t\t\t\t/>"
},
{
"type": "DeletedLine",
"lineBefore": 164,
"content": "\t\t\t\t\t<Field"
},
{
"type": "DeletedLine",
"lineBefore": 165,
"content": "\t\t\t\t\t\tlabelProps={{ htmlFor: fields.password.id, children: 'Password' }}"
},
{
"type": "DeletedLine",
"lineBefore": 166,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "DeletedLine",
"lineBefore": 167,
"content": "\t\t\t\t\t\t\t...conform.input(fields.password, { type: 'password' }),"
},
{
"type": "DeletedLine",
"lineBefore": 168,
"content": "\t\t\t\t\t\t\tautoComplete: 'new-password',"
},
{
"type": "DeletedLine",
"lineBefore": 169,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "DeletedLine",
"lineBefore": 170,
"content": "\t\t\t\t\t\terrors={fields.password.errors}"
},
{
"type": "DeletedLine",
"lineBefore": 171,
"content": "\t\t\t\t\t/>"
},
{
"type": "DeletedLine",
"lineBefore": 172,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\t\t<div className=\"container flex flex-col justify-center pb-32 pt-20\">"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\t\t\t<div className=\"text-center\">"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t\t\t\t<h1 className=\"text-h1\">Let's start your journey!</h1>"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t\t\t\t<p className=\"mt-3 text-body-md text-muted-foreground\">"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": "\t\t\t\t\tPlease enter your email."
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\t\t\t<div className=\"mx-auto mt-16 min-w-[368px] max-w-sm\">"
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": "\t\t\t\t<Form method=\"POST\" {...form.props}>"
},
{
"type": "UnchangedLine",
"lineBefore": 173,
"lineAfter": 132,
"content": "\t\t\t\t\t<Field"
},
{
"type": "UnchangedLine",
"lineBefore": 174,
"lineAfter": 133,
"content": "\t\t\t\t\t\tlabelProps={{"
},
{
"type": "DeletedLine",
"lineBefore": 175,
"content": "\t\t\t\t\t\t\thtmlFor: fields.confirmPassword.id,"
},
{
"type": "DeletedLine",
"lineBefore": 176,
"content": "\t\t\t\t\t\t\tchildren: 'Confirm Password',"
},
{
"type": "DeletedLine",
"lineBefore": 177,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "DeletedLine",
"lineBefore": 178,
"content": "\t\t\t\t\t\tinputProps={{"
},
{
"type": "DeletedLine",
"lineBefore": 179,
"content": "\t\t\t\t\t\t\t...conform.input(fields.confirmPassword, { type: 'password' }),"
},
{
"type": "DeletedLine",
"lineBefore": 180,
"content": "\t\t\t\t\t\t\tautoComplete: 'new-password',"
},
{
"type": "DeletedLine",
"lineBefore": 181,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "DeletedLine",
"lineBefore": 182,
"content": "\t\t\t\t\t\terrors={fields.confirmPassword.errors}"
},
{
"type": "DeletedLine",
"lineBefore": 183,
"content": "\t\t\t\t\t/>"
},
{
"type": "DeletedLine",
"lineBefore": 184,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 185,
"content": "\t\t\t\t\t<CheckboxField"
},
{
"type": "DeletedLine",
"lineBefore": 186,
"content": "\t\t\t\t\t\tlabelProps={{"
},
{
"type": "DeletedLine",
"lineBefore": 187,
"content": "\t\t\t\t\t\t\thtmlFor: fields.agreeToTermsOfServiceAndPrivacyPolicy.id,"
},
{
"type": "DeletedLine",
"lineBefore": 188,
"content": "\t\t\t\t\t\t\tchildren:"
},
{
"type": "DeletedLine",
"lineBefore": 189,
"content": "\t\t\t\t\t\t\t\t'Do you agree to our Terms of Service and Privacy Policy?',"
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\t\t\t\t\t\t\thtmlFor: fields.email.id,"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\t\t\t\t\t\t\tchildren: 'Email',"
},
{
"type": "UnchangedLine",
"lineBefore": 190,
"lineAfter": 136,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "DeletedLine",
"lineBefore": 191,
"content": "\t\t\t\t\t\tbuttonProps={conform.input("
},
{
"type": "DeletedLine",
"lineBefore": 192,
"content": "\t\t\t\t\t\t\tfields.agreeToTermsOfServiceAndPrivacyPolicy,"
},
{
"type": "DeletedLine",
"lineBefore": 193,
"content": "\t\t\t\t\t\t\t{ type: 'checkbox' },"
},
{
"type": "DeletedLine",
"lineBefore": 194,
"content": "\t\t\t\t\t\t)}"
},
{
"type": "DeletedLine",
"lineBefore": 195,
"content": "\t\t\t\t\t\terrors={fields.agreeToTermsOfServiceAndPrivacyPolicy.errors}"
},
{
"type": "DeletedLine",
"lineBefore": 196,
"content": "\t\t\t\t\t/>"
},
{
"type": "DeletedLine",
"lineBefore": 197,
"content": "\t\t\t\t\t<CheckboxField"
},
{
"type": "DeletedLine",
"lineBefore": 198,
"content": "\t\t\t\t\t\tlabelProps={{"
},
{
"type": "DeletedLine",
"lineBefore": 199,
"content": "\t\t\t\t\t\t\thtmlFor: fields.remember.id,"
},
{
"type": "DeletedLine",
"lineBefore": 200,
"content": "\t\t\t\t\t\t\tchildren: 'Remember me',"
},
{
"type": "DeletedLine",
"lineBefore": 201,
"content": "\t\t\t\t\t\t}}"
},
{
"type": "DeletedLine",
"lineBefore": 202,
"content": "\t\t\t\t\t\tbuttonProps={conform.input(fields.remember, { type: 'checkbox' })}"
},
{
"type": "DeletedLine",
"lineBefore": 203,
"content": "\t\t\t\t\t\terrors={fields.remember.errors}"
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\t\t\t\t\t\tinputProps={{ ...conform.input(fields.email), autoFocus: true }}"
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "\t\t\t\t\t\terrors={fields.email.errors}"
},
{
"type": "UnchangedLine",
"lineBefore": 204,
"lineAfter": 139,
"content": "\t\t\t\t\t/>"
},
{
"type": "DeletedLine",
"lineBefore": 205,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 206,
"content": "\t\t\t\t\t<input {...conform.input(fields.redirectTo)} type=\"hidden\" />"
},
{
"type": "UnchangedLine",
"lineBefore": 207,
"lineAfter": 140,
"content": "\t\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />"
},
{
"type": "DeletedLine",
"lineBefore": 208,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 209,
"content": "\t\t\t\t\t<div className=\"flex items-center justify-between gap-6\">"
},
{
"type": "DeletedLine",
"lineBefore": 210,
"content": "\t\t\t\t\t\t<StatusButton"
},
{
"type": "DeletedLine",
"lineBefore": 211,
"content": "\t\t\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "DeletedLine",
"lineBefore": 212,
"content": "\t\t\t\t\t\t\tstatus={isPending ? 'pending' : actionData?.status ?? 'idle'}"
},
{
"type": "DeletedLine",
"lineBefore": 213,
"content": "\t\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "DeletedLine",
"lineBefore": 214,
"content": "\t\t\t\t\t\t\tdisabled={isPending}"
},
{
"type": "DeletedLine",
"lineBefore": 215,
"content": "\t\t\t\t\t\t>"
},
{
"type": "DeletedLine",
"lineBefore": 216,
"content": "\t\t\t\t\t\t\tCreate an account"
},
{
"type": "DeletedLine",
"lineBefore": 217,
"content": "\t\t\t\t\t\t</StatusButton>"
},
{
"type": "DeletedLine",
"lineBefore": 218,
"content": "\t\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\t\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\t\t\t\t\t\tstatus={isPending ? 'pending' : actionData?.status ?? 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": "\t\t\t\t\t\tdisabled={isPending}"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": "\t\t\t\t\t\tSubmit"
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "\t\t\t\t</Form>"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "\t\t\t\t<Form"
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "\t\t\t\t\tclassName=\"mt-5 flex items-center justify-center gap-2 border-t-2 border-border pt-3\""
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "\t\t\t\t\taction=\"/auth/github\""
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "\t\t\t\t\tmethod=\"POST\""
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": "\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "\t\t\t\t\t<input type=\"hidden\" name=\"redirectTo\" value={redirectTo ?? '/'} />"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "\t\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": "\t\t\t\t\t\tstatus={isGitHubSubmitting ? 'pending' : 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": "\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": "\t\t\t\t\t\tSign up with GitHub"
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": "\t\t\t\t\t</StatusButton>"
},
{
"type": "UnchangedLine",
"lineBefore": 219,
"lineAfter": 163,
"content": "\t\t\t\t</Form>"
},
{
"type": "UnchangedLine",
"lineBefore": 220,
"lineAfter": 164,
"content": "\t\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 221,
"lineAfter": 165,
"content": "\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 222,
"lineAfter": 166,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 223,
"lineAfter": 167,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": "export function ErrorBoundary() {"
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": "\treturn <GeneralErrorBoundary />"
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/signup.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 331
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { conform, useForm, type Submission } from '@conform-to/react'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { getFieldsetConstraint, parse } from '@conform-to/zod'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import { generateTOTP, verifyTOTP } from '@epic-web/totp'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "import { json, type DataFunctionArgs } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\tForm,"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\tuseActionData,"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "\tuseLoaderData,"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "\tuseSearchParams,"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "} from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "import { ErrorList, Field } from '~/components/forms.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "import { Spacer } from '~/components/spacer.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "import { handleVerification as handleChangeEmailVerification } from '~/routes/settings+/profile.change-email.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "import { getDomainUrl, useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "import { type twoFAVerifyVerificationType } from '../settings+/profile.two-factor.verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "\thandleVerification as handleLoginTwoFactorVerification,"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "\tshouldRequestTwoFA,"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "} from './login.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "import { handleVerification as handleOnboardingVerification } from './onboarding.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "import { handleVerification as handleResetPasswordVerification } from './reset-password.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "import { requireUserId } from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "import { twoFAVerificationType } from '../settings+/profile.two-factor.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "import { redirectWithToast } from '~/utils/toast.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "export const codeQueryParam = 'code'"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "export const targetQueryParam = 'target'"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "export const typeQueryParam = 'type'"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "export const redirectToQueryParam = 'redirectTo'"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "const types = ['onboarding', 'reset-password', 'change-email', '2fa'] as const"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "const VerificationTypeSchema = z.enum(types)"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "export type VerificationTypes = z.infer<typeof VerificationTypeSchema>"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "const VerifySchema = z.object({"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t[codeQueryParam]: z.string().min(6).max(6),"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t[typeQueryParam]: VerificationTypeSchema,"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t[targetQueryParam]: z.string(),"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t[redirectToQueryParam]: z.string().optional(),"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "})"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\tconst params = new URL(request.url).searchParams"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\tif (!params.has(codeQueryParam)) {"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\t\t// we don't want to show an error message on page load if the otp hasn't be"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\t\t// prefilled in yet, so we'll send a response with an empty submission."
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\t\treturn json({"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t\t\tstatus: 'idle',"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\t\tsubmission: {"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t\t\t\tintent: '',"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t\t\t\tpayload: Object.fromEntries(params),"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\t\t\t\terror: {},"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\t} as const)"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\treturn validateRequest(request, params)"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\treturn validateRequest(request, await request.formData())"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "export function getRedirectToUrl({"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\ttype,"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\ttarget,"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\tredirectTo,"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "}: {"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\trequest: Request"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\ttype: VerificationTypes"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\ttarget: string"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\tredirectTo?: string"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "}) {"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\tconst redirectToUrl = new URL(`${getDomainUrl(request)}/verify`)"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\tredirectToUrl.searchParams.set(typeQueryParam, type)"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "\tredirectToUrl.searchParams.set(targetQueryParam, target)"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\tif (redirectTo) {"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\tredirectToUrl.searchParams.set(redirectToQueryParam, redirectTo)"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\treturn redirectToUrl"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "export async function requireRecentVerification(request: Request) {"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\tconst shouldReverify = await shouldRequestTwoFA(request)"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "\tif (shouldReverify) {"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\t\tconst reqUrl = new URL(request.url)"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\t\tconst redirectUrl = getRedirectToUrl({"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\t\t\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\t\t\ttarget: userId,"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\t\t\ttype: twoFAVerificationType,"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t\t\tredirectTo: reqUrl.pathname + reqUrl.search,"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\t\tthrow await redirectWithToast(redirectUrl.toString(), {"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\t\t\ttitle: 'Please Reverify',"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "\t\t\tdescription: 'Please reverify your account before proceeding',"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "export async function prepareVerification({"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": "\tperiod,"
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\ttype,"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "\ttarget,"
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": "}: {"
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "\tperiod: number"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "\trequest: Request"
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": "\ttype: VerificationTypes"
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "\ttarget: string"
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": "}) {"
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": "\tconst verifyUrl = getRedirectToUrl({ request, type, target })"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "\tconst redirectTo = new URL(verifyUrl.toString())"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": "\tconst { otp, ...verificationConfig } = generateTOTP({"
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\t\talgorithm: 'SHA256',"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\t\tperiod,"
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "\tconst verificationData = {"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\t\ttype,"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\t\ttarget,"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t\t...verificationConfig,"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t\texpiresAt: new Date(Date.now() + verificationConfig.period * 1000),"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "\tawait prisma.verification.upsert({"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "\t\twhere: { target_type: { target, type } },"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\t\tcreate: verificationData,"
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": "\t\tupdate: verificationData,"
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\t// add the otp to the url we'll email the user."
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\tverifyUrl.searchParams.set(codeQueryParam, otp)"
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\treturn { otp, redirectTo, verifyUrl }"
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "export type VerifyFunctionArgs = {"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\trequest: Request"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\tsubmission: Submission<z.infer<typeof VerifySchema>>"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\tbody: FormData | URLSearchParams"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "export async function isCodeValid({"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": "\tcode,"
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\ttype,"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "\ttarget,"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "}: {"
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "\tcode: string"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "\ttype: VerificationTypes | typeof twoFAVerifyVerificationType"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "\ttarget: string"
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": "}) {"
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "\tconst verification = await prisma.verification.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "\t\twhere: {"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\t\t\ttarget_type: { target, type },"
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "\t\t\tOR: [{ expiresAt: { gt: new Date() } }, { expiresAt: null }],"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": "\t\tselect: { algorithm: true, secret: true, period: true },"
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": "\tif (!verification) return false"
},
{
"type": "AddedLine",
"lineAfter": 163,
"content": "\tconst result = verifyTOTP({"
},
{
"type": "AddedLine",
"lineAfter": 164,
"content": "\t\totp: code,"
},
{
"type": "AddedLine",
"lineAfter": 165,
"content": "\t\tsecret: verification.secret,"
},
{
"type": "AddedLine",
"lineAfter": 166,
"content": "\t\talgorithm: verification.algorithm,"
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": "\t\tperiod: verification.period,"
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": "\tif (!result) return false"
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "\treturn true"
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 173,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 174,
"content": "async function validateRequest("
},
{
"type": "AddedLine",
"lineAfter": 175,
"content": "\trequest: Request,"
},
{
"type": "AddedLine",
"lineAfter": 176,
"content": "\tbody: URLSearchParams | FormData,"
},
{
"type": "AddedLine",
"lineAfter": 177,
"content": ") {"
},
{
"type": "AddedLine",
"lineAfter": 178,
"content": "\tconst submission = await parse(body, {"
},
{
"type": "AddedLine",
"lineAfter": 179,
"content": "\t\tschema: () =>"
},
{
"type": "AddedLine",
"lineAfter": 180,
"content": "\t\t\tVerifySchema.superRefine(async (data, ctx) => {"
},
{
"type": "AddedLine",
"lineAfter": 181,
"content": "\t\t\t\tconst codeIsValid = await isCodeValid({"
},
{
"type": "AddedLine",
"lineAfter": 182,
"content": "\t\t\t\t\tcode: data[codeQueryParam],"
},
{
"type": "AddedLine",
"lineAfter": 183,
"content": "\t\t\t\t\ttype: data[typeQueryParam],"
},
{
"type": "AddedLine",
"lineAfter": 184,
"content": "\t\t\t\t\ttarget: data[targetQueryParam],"
},
{
"type": "AddedLine",
"lineAfter": 185,
"content": "\t\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 186,
"content": "\t\t\t\tif (!codeIsValid) {"
},
{
"type": "AddedLine",
"lineAfter": 187,
"content": "\t\t\t\t\tctx.addIssue({"
},
{
"type": "AddedLine",
"lineAfter": 188,
"content": "\t\t\t\t\t\tpath: ['code'],"
},
{
"type": "AddedLine",
"lineAfter": 189,
"content": "\t\t\t\t\t\tcode: z.ZodIssueCode.custom,"
},
{
"type": "AddedLine",
"lineAfter": 190,
"content": "\t\t\t\t\t\tmessage: `Invalid code`,"
},
{
"type": "AddedLine",
"lineAfter": 191,
"content": "\t\t\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 192,
"content": "\t\t\t\t\treturn"
},
{
"type": "AddedLine",
"lineAfter": 193,
"content": "\t\t\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 194,
"content": "\t\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 195,
"content": "\t\tacceptMultipleErrors: () => true,"
},
{
"type": "AddedLine",
"lineAfter": 196,
"content": "\t\tasync: true,"
},
{
"type": "AddedLine",
"lineAfter": 197,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 198,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 199,
"content": "\tif (submission.intent !== 'submit') {"
},
{
"type": "AddedLine",
"lineAfter": 200,
"content": "\t\treturn json({ status: 'idle', submission } as const)"
},
{
"type": "AddedLine",
"lineAfter": 201,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 202,
"content": "\tif (!submission.value) {"
},
{
"type": "AddedLine",
"lineAfter": 203,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "AddedLine",
"lineAfter": 204,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 205,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 206,
"content": "\tconst { value: submissionValue } = submission"
},
{
"type": "AddedLine",
"lineAfter": 207,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 208,
"content": "\tasync function deleteVerification() {"
},
{
"type": "AddedLine",
"lineAfter": 209,
"content": "\t\tawait prisma.verification.delete({"
},
{
"type": "AddedLine",
"lineAfter": 210,
"content": "\t\t\twhere: {"
},
{
"type": "AddedLine",
"lineAfter": 211,
"content": "\t\t\t\ttarget_type: {"
},
{
"type": "AddedLine",
"lineAfter": 212,
"content": "\t\t\t\t\ttype: submissionValue[typeQueryParam],"
},
{
"type": "AddedLine",
"lineAfter": 213,
"content": "\t\t\t\t\ttarget: submissionValue[targetQueryParam],"
},
{
"type": "AddedLine",
"lineAfter": 214,
"content": "\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 215,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 216,
"content": "\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 217,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 218,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 219,
"content": "\tswitch (submissionValue[typeQueryParam]) {"
},
{
"type": "AddedLine",
"lineAfter": 220,
"content": "\t\tcase 'reset-password': {"
},
{
"type": "AddedLine",
"lineAfter": 221,
"content": "\t\t\tawait deleteVerification()"
},
{
"type": "AddedLine",
"lineAfter": 222,
"content": "\t\t\treturn handleResetPasswordVerification({ request, body, submission })"
},
{
"type": "AddedLine",
"lineAfter": 223,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 224,
"content": "\t\tcase 'onboarding': {"
},
{
"type": "AddedLine",
"lineAfter": 225,
"content": "\t\t\tawait deleteVerification()"
},
{
"type": "AddedLine",
"lineAfter": 226,
"content": "\t\t\treturn handleOnboardingVerification({ request, body, submission })"
},
{
"type": "AddedLine",
"lineAfter": 227,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 228,
"content": "\t\tcase 'change-email': {"
},
{
"type": "AddedLine",
"lineAfter": 229,
"content": "\t\t\tawait deleteVerification()"
},
{
"type": "AddedLine",
"lineAfter": 230,
"content": "\t\t\treturn handleChangeEmailVerification({ request, body, submission })"
},
{
"type": "AddedLine",
"lineAfter": 231,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 232,
"content": "\t\tcase '2fa': {"
},
{
"type": "AddedLine",
"lineAfter": 233,
"content": "\t\t\treturn handleLoginTwoFactorVerification({ request, body, submission })"
},
{
"type": "AddedLine",
"lineAfter": 234,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 235,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 236,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 237,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 238,
"content": "export default function VerifyRoute() {"
},
{
"type": "AddedLine",
"lineAfter": 239,
"content": "\tconst data = useLoaderData<typeof loader>()"
},
{
"type": "AddedLine",
"lineAfter": 240,
"content": "\tconst [searchParams] = useSearchParams()"
},
{
"type": "AddedLine",
"lineAfter": 241,
"content": "\tconst isPending = useIsPending()"
},
{
"type": "AddedLine",
"lineAfter": 242,
"content": "\tconst actionData = useActionData<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 243,
"content": "\tconst type = VerificationTypeSchema.parse(searchParams.get(typeQueryParam))"
},
{
"type": "AddedLine",
"lineAfter": 244,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 245,
"content": "\tconst checkEmail = ("
},
{
"type": "AddedLine",
"lineAfter": 246,
"content": "\t\t<>"
},
{
"type": "AddedLine",
"lineAfter": 247,
"content": "\t\t\t<h1 className=\"text-h1\">Check your email</h1>"
},
{
"type": "AddedLine",
"lineAfter": 248,
"content": "\t\t\t<p className=\"mt-3 text-body-md text-muted-foreground\">"
},
{
"type": "AddedLine",
"lineAfter": 249,
"content": "\t\t\t\tWe've sent you a code to verify your email address."
},
{
"type": "AddedLine",
"lineAfter": 250,
"content": "\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 251,
"content": "\t\t</>"
},
{
"type": "AddedLine",
"lineAfter": 252,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 253,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 254,
"content": "\tconst headings: Record<VerificationTypes, React.ReactNode> = {"
},
{
"type": "AddedLine",
"lineAfter": 255,
"content": "\t\tonboarding: checkEmail,"
},
{
"type": "AddedLine",
"lineAfter": 256,
"content": "\t\t'reset-password': checkEmail,"
},
{
"type": "AddedLine",
"lineAfter": 257,
"content": "\t\t'change-email': checkEmail,"
},
{
"type": "AddedLine",
"lineAfter": 258,
"content": "\t\t'2fa': ("
},
{
"type": "AddedLine",
"lineAfter": 259,
"content": "\t\t\t<>"
},
{
"type": "AddedLine",
"lineAfter": 260,
"content": "\t\t\t\t<h1 className=\"text-h1\">Check your 2FA app</h1>"
},
{
"type": "AddedLine",
"lineAfter": 261,
"content": "\t\t\t\t<p className=\"mt-3 text-body-md text-muted-foreground\">"
},
{
"type": "AddedLine",
"lineAfter": 262,
"content": "\t\t\t\t\tPlease enter your 2FA code to verify your identity."
},
{
"type": "AddedLine",
"lineAfter": 263,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 264,
"content": "\t\t\t</>"
},
{
"type": "AddedLine",
"lineAfter": 265,
"content": "\t\t),"
},
{
"type": "AddedLine",
"lineAfter": 266,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 267,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 268,
"content": "\tconst [form, fields] = useForm({"
},
{
"type": "AddedLine",
"lineAfter": 269,
"content": "\t\tid: 'verify-form',"
},
{
"type": "AddedLine",
"lineAfter": 270,
"content": "\t\tconstraint: getFieldsetConstraint(VerifySchema),"
},
{
"type": "AddedLine",
"lineAfter": 271,
"content": "\t\tlastSubmission: actionData?.submission ?? data.submission,"
},
{
"type": "AddedLine",
"lineAfter": 272,
"content": "\t\tonValidate({ formData }) {"
},
{
"type": "AddedLine",
"lineAfter": 273,
"content": "\t\t\treturn parse(formData, { schema: VerifySchema })"
},
{
"type": "AddedLine",
"lineAfter": 274,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 275,
"content": "\t\tdefaultValue: {"
},
{
"type": "AddedLine",
"lineAfter": 276,
"content": "\t\t\tcode: searchParams.get(codeQueryParam) ?? '',"
},
{
"type": "AddedLine",
"lineAfter": 277,
"content": "\t\t\ttype,"
},
{
"type": "AddedLine",
"lineAfter": 278,
"content": "\t\t\ttarget: searchParams.get(targetQueryParam) ?? '',"
},
{
"type": "AddedLine",
"lineAfter": 279,
"content": "\t\t\tredirectTo: searchParams.get(redirectToQueryParam) ?? '',"
},
{
"type": "AddedLine",
"lineAfter": 280,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 281,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 282,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 283,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 284,
"content": "\t\t<div className=\"container flex flex-col justify-center pb-32 pt-20\">"
},
{
"type": "AddedLine",
"lineAfter": 285,
"content": "\t\t\t<div className=\"text-center\">{headings[type]}</div>"
},
{
"type": "AddedLine",
"lineAfter": 286,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 287,
"content": "\t\t\t<Spacer size=\"xs\" />"
},
{
"type": "AddedLine",
"lineAfter": 288,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 289,
"content": "\t\t\t<div className=\"mx-auto flex flex-col justify-center gap-1 w-72 max-w-full\">"
},
{
"type": "AddedLine",
"lineAfter": 290,
"content": "\t\t\t\t<div>"
},
{
"type": "AddedLine",
"lineAfter": 291,
"content": "\t\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />"
},
{
"type": "AddedLine",
"lineAfter": 292,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 293,
"content": "\t\t\t\t<div className=\"flex w-full gap-2\">"
},
{
"type": "AddedLine",
"lineAfter": 294,
"content": "\t\t\t\t\t<Form method=\"POST\" {...form.props} className=\"flex-1\">"
},
{
"type": "AddedLine",
"lineAfter": 295,
"content": "\t\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 296,
"content": "\t\t\t\t\t\t\tlabelProps={{"
},
{
"type": "AddedLine",
"lineAfter": 297,
"content": "\t\t\t\t\t\t\t\thtmlFor: fields[codeQueryParam].id,"
},
{
"type": "AddedLine",
"lineAfter": 298,
"content": "\t\t\t\t\t\t\t\tchildren: 'Code',"
},
{
"type": "AddedLine",
"lineAfter": 299,
"content": "\t\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 300,
"content": "\t\t\t\t\t\t\tinputProps={conform.input(fields[codeQueryParam])}"
},
{
"type": "AddedLine",
"lineAfter": 301,
"content": "\t\t\t\t\t\t\terrors={fields[codeQueryParam].errors}"
},
{
"type": "AddedLine",
"lineAfter": 302,
"content": "\t\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 303,
"content": "\t\t\t\t\t\t<input"
},
{
"type": "AddedLine",
"lineAfter": 304,
"content": "\t\t\t\t\t\t\t{...conform.input(fields[typeQueryParam], { type: 'hidden' })}"
},
{
"type": "AddedLine",
"lineAfter": 305,
"content": "\t\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 306,
"content": "\t\t\t\t\t\t<input"
},
{
"type": "AddedLine",
"lineAfter": 307,
"content": "\t\t\t\t\t\t\t{...conform.input(fields[targetQueryParam], { type: 'hidden' })}"
},
{
"type": "AddedLine",
"lineAfter": 308,
"content": "\t\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 309,
"content": "\t\t\t\t\t\t<input"
},
{
"type": "AddedLine",
"lineAfter": 310,
"content": "\t\t\t\t\t\t\t{...conform.input(fields[redirectToQueryParam], {"
},
{
"type": "AddedLine",
"lineAfter": 311,
"content": "\t\t\t\t\t\t\t\ttype: 'hidden',"
},
{
"type": "AddedLine",
"lineAfter": 312,
"content": "\t\t\t\t\t\t\t})}"
},
{
"type": "AddedLine",
"lineAfter": 313,
"content": "\t\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 314,
"content": "\t\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 315,
"content": "\t\t\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "AddedLine",
"lineAfter": 316,
"content": "\t\t\t\t\t\t\tstatus={isPending ? 'pending' : actionData?.status ?? 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 317,
"content": "\t\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 318,
"content": "\t\t\t\t\t\t\tdisabled={isPending}"
},
{
"type": "AddedLine",
"lineAfter": 319,
"content": "\t\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 320,
"content": "\t\t\t\t\t\t\tSubmit"
},
{
"type": "AddedLine",
"lineAfter": 321,
"content": "\t\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 322,
"content": "\t\t\t\t\t</Form>"
},
{
"type": "AddedLine",
"lineAfter": 323,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 324,
"content": "\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 325,
"content": "\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 326,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 327,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 328,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 329,
"content": "export function ErrorBoundary() {"
},
{
"type": "AddedLine",
"lineAfter": 330,
"content": "\treturn <GeneralErrorBoundary />"
},
{
"type": "AddedLine",
"lineAfter": 331,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/_auth+/verify.tsx"
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "import { requireUserId } from '~/utils/auth.server.ts'",
"type": "Chunk",
"toFileRange": {
"start": 3,
"lines": 6
},
"fromFileRange": {
"start": 3,
"lines": 8
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 3,
"lineAfter": 3,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 4,
"lineAfter": 4,
"content": "import { getDomainUrl } from '~/utils/misc.tsx'"
},
{
"type": "UnchangedLine",
"lineBefore": 5,
"lineAfter": 5,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 6,
"content": "export const ROUTE_PATH = '/resources/download-user-data'"
},
{
"type": "DeletedLine",
"lineBefore": 7,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 8,
"lineAfter": 6,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "UnchangedLine",
"lineBefore": 9,
"lineAfter": 7,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "UnchangedLine",
"lineBefore": 10,
"lineAfter": 8,
"content": "\tconst user = await prisma.user.findUniqueOrThrow({"
}
]
},
{
"context": "export async function loader({ request }: DataFunctionArgs) {",
"type": "Chunk",
"toFileRange": {
"start": 35,
"lines": 6
},
"fromFileRange": {
"start": 37,
"lines": 7
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 37,
"lineAfter": 35,
"content": "\t\t\t},"
},
{
"type": "UnchangedLine",
"lineBefore": 38,
"lineAfter": 36,
"content": "\t\t\tpassword: false, // <-- intentionally omit password"
},
{
"type": "UnchangedLine",
"lineBefore": 39,
"lineAfter": 37,
"content": "\t\t\tsessions: true,"
},
{
"type": "DeletedLine",
"lineBefore": 40,
"content": "\t\t\troles: true,"
},
{
"type": "UnchangedLine",
"lineBefore": 41,
"lineAfter": 38,
"content": "\t\t},"
},
{
"type": "UnchangedLine",
"lineBefore": 42,
"lineAfter": 39,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 43,
"lineAfter": 40,
"content": ""
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/resources+/download-user-data.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 245
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { conform, useForm } from '@conform-to/react'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { getFieldsetConstraint, parse } from '@conform-to/zod'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import * as E from '@react-email/components'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "import { json, redirect, type DataFunctionArgs } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "import { Form, useActionData, useLoaderData } from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "import { ErrorList, Field } from '~/components/forms.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "import { Icon } from '~/components/ui/icon.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "\tprepareVerification,"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\trequireRecentVerification,"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\ttype VerifyFunctionArgs,"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "} from '~/routes/_auth+/verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "import { requireUserId } from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "import { sendEmail } from '~/utils/email.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "import { invariant, useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "import { redirectWithToast } from '~/utils/toast.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "import { emailSchema } from '~/utils/user-validation.ts'"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "import { verifySessionStorage } from '~/utils/verification.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "export const handle = {"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "\tbreadcrumb: <Icon name=\"envelope-closed\">Change Email</Icon>,"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "const newEmailAddressSessionKey = 'new-email-address'"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "export async function handleVerification({"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\tsubmission,"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "}: VerifyFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "\tawait requireRecentVerification(request)"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\tinvariant(submission.value, 'submission.value should be defined by now')"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\tconst newEmail = verifySession.get(newEmailAddressSessionKey)"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\tif (!newEmail) {"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t\tsubmission.error[''] = ["
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t\t\t'You must submit the code on the same device that requested the email change.',"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\t]"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\tconst preUpdateUser = await prisma.user.findFirstOrThrow({"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\t\tselect: { email: true },"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\t\twhere: { id: submission.value.target },"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\tconst user = await prisma.user.update({"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t\twhere: { id: submission.value.target },"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\tselect: { id: true, email: true, username: true },"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t\tdata: { email: newEmail },"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\tvoid sendEmail({"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\tto: preUpdateUser.email,"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t\tsubject: 'Epic Stack email changed',"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\t\treact: <EmailChangeNoticeEmail userId={user.id} />,"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\treturn redirectWithToast("
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t\t'/settings/profile',"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\t\t\ttitle: 'Email Changed',"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\t\t\ttype: 'success',"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\t\t\tdescription: `Your email has been changed to ${user.email}`,"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\t\t\theaders: {"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\t\t\t\t'set-cookie': await verifySessionStorage.destroySession(verifySession),"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "const ChangeEmailSchema = z.object({"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\temail: emailSchema,"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "})"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\tawait requireRecentVerification(request)"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "\tconst user = await prisma.user.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\t\twhere: { id: userId },"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\t\tselect: { email: true },"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\tif (!user) {"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "\t\tconst params = new URLSearchParams({ redirectTo: request.url })"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\t\tthrow redirect(`/login?${params}`)"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\treturn json({ user })"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\tconst formData = await request.formData()"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\tconst submission = await parse(formData, {"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "\t\tschema: ChangeEmailSchema.superRefine(async (data, ctx) => {"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\t\t\tconst existingUser = await prisma.user.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t\t\t\twhere: { email: data.email },"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": "\t\t\tif (existingUser) {"
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "\t\t\t\tctx.addIssue({"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": "\t\t\t\t\tpath: ['email'],"
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "\t\t\t\t\tcode: 'custom',"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\t\t\t\t\tmessage: 'This email is already in use.',"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "\t\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": "\t\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "\t\tasync: true,"
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": "\t\tacceptMultipleErrors: () => true,"
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": "\tif (submission.intent !== 'submit') {"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "\t\treturn json({ status: 'idle', submission } as const)"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": "\tif (!submission.value) {"
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\tconst { otp, redirectTo, verifyUrl } = await prepareVerification({"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "\t\tperiod: 10 * 60,"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\t\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\t\ttarget: userId,"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t\ttype: 'change-email',"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "\tconst response = await sendEmail({"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "\t\tto: submission.value.email,"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\t\tsubject: `Epic Notes Email Change Verification`,"
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": "\t\treact: <EmailChangeEmail verifyUrl={verifyUrl.toString()} otp={otp} />,"
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\tif (response.status === 'success') {"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\t\tconst verifySession = await verifySessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": "\t\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "\t\tverifySession.set(newEmailAddressSessionKey, submission.value.email)"
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": "\t\treturn redirect(redirectTo.toString(), {"
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "\t\t\theaders: {"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\t\t\t\t'set-cookie': await verifySessionStorage.commitSession(verifySession),"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "\t} else {"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": "\t\tsubmission.error[''] = response.error.message"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 500 })"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "export function EmailChangeEmail({"
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "\tverifyUrl,"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "\totp,"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "}: {"
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": "\tverifyUrl: string"
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "\totp: string"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "}) {"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "\t\t<E.Html lang=\"en\" dir=\"ltr\">"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": "\t\t\t<E.Container>"
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": "\t\t\t\t<h1>"
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": "\t\t\t\t\t<E.Text>Epic Notes Email Change</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": "\t\t\t\t</h1>"
},
{
"type": "AddedLine",
"lineAfter": 163,
"content": "\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 164,
"content": "\t\t\t\t\t<E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 165,
"content": "\t\t\t\t\t\tHere's your verification code: <strong>{otp}</strong>"
},
{
"type": "AddedLine",
"lineAfter": 166,
"content": "\t\t\t\t\t</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": "\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": "\t\t\t\t\t<E.Text>Or click the link:</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "\t\t\t\t<E.Link href={verifyUrl}>{verifyUrl}</E.Link>"
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "\t\t\t</E.Container>"
},
{
"type": "AddedLine",
"lineAfter": 173,
"content": "\t\t</E.Html>"
},
{
"type": "AddedLine",
"lineAfter": 174,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 175,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 176,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 177,
"content": "export function EmailChangeNoticeEmail({ userId }: { userId: string }) {"
},
{
"type": "AddedLine",
"lineAfter": 178,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 179,
"content": "\t\t<E.Html lang=\"en\" dir=\"ltr\">"
},
{
"type": "AddedLine",
"lineAfter": 180,
"content": "\t\t\t<E.Container>"
},
{
"type": "AddedLine",
"lineAfter": 181,
"content": "\t\t\t\t<h1>"
},
{
"type": "AddedLine",
"lineAfter": 182,
"content": "\t\t\t\t\t<E.Text>Your Epic Notes email has been changed</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 183,
"content": "\t\t\t\t</h1>"
},
{
"type": "AddedLine",
"lineAfter": 184,
"content": "\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 185,
"content": "\t\t\t\t\t<E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 186,
"content": "\t\t\t\t\t\tWe're writing to let you know that your Epic Notes email has been"
},
{
"type": "AddedLine",
"lineAfter": 187,
"content": "\t\t\t\t\t\tchanged."
},
{
"type": "AddedLine",
"lineAfter": 188,
"content": "\t\t\t\t\t</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 189,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 190,
"content": "\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 191,
"content": "\t\t\t\t\t<E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 192,
"content": "\t\t\t\t\t\tIf you changed your email address, then you can safely ignore this."
},
{
"type": "AddedLine",
"lineAfter": 193,
"content": "\t\t\t\t\t\tBut if you did not change your email address, then please contact"
},
{
"type": "AddedLine",
"lineAfter": 194,
"content": "\t\t\t\t\t\tsupport immediately."
},
{
"type": "AddedLine",
"lineAfter": 195,
"content": "\t\t\t\t\t</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 196,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 197,
"content": "\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 198,
"content": "\t\t\t\t\t<E.Text>Your Account ID: {userId}</E.Text>"
},
{
"type": "AddedLine",
"lineAfter": 199,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 200,
"content": "\t\t\t</E.Container>"
},
{
"type": "AddedLine",
"lineAfter": 201,
"content": "\t\t</E.Html>"
},
{
"type": "AddedLine",
"lineAfter": 202,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 203,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 204,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 205,
"content": "export default function ChangeEmailIndex() {"
},
{
"type": "AddedLine",
"lineAfter": 206,
"content": "\tconst data = useLoaderData<typeof loader>()"
},
{
"type": "AddedLine",
"lineAfter": 207,
"content": "\tconst actionData = useActionData<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 208,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 209,
"content": "\tconst [form, fields] = useForm({"
},
{
"type": "AddedLine",
"lineAfter": 210,
"content": "\t\tid: 'change-email-form',"
},
{
"type": "AddedLine",
"lineAfter": 211,
"content": "\t\tconstraint: getFieldsetConstraint(ChangeEmailSchema),"
},
{
"type": "AddedLine",
"lineAfter": 212,
"content": "\t\tlastSubmission: actionData?.submission,"
},
{
"type": "AddedLine",
"lineAfter": 213,
"content": "\t\tonValidate({ formData }) {"
},
{
"type": "AddedLine",
"lineAfter": 214,
"content": "\t\t\treturn parse(formData, { schema: ChangeEmailSchema })"
},
{
"type": "AddedLine",
"lineAfter": 215,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 216,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 217,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 218,
"content": "\tconst isPending = useIsPending()"
},
{
"type": "AddedLine",
"lineAfter": 219,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 220,
"content": "\t\t<div>"
},
{
"type": "AddedLine",
"lineAfter": 221,
"content": "\t\t\t<h1 className=\"text-h1\">Change Email</h1>"
},
{
"type": "AddedLine",
"lineAfter": 222,
"content": "\t\t\t<p>You will receive an email at the new email address to confirm.</p>"
},
{
"type": "AddedLine",
"lineAfter": 223,
"content": "\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 224,
"content": "\t\t\t\tAn email notice will also be sent to your old address {data.user.email}."
},
{
"type": "AddedLine",
"lineAfter": 225,
"content": "\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 226,
"content": "\t\t\t<div className=\"mx-auto mt-5 max-w-sm\">"
},
{
"type": "AddedLine",
"lineAfter": 227,
"content": "\t\t\t\t<Form method=\"POST\" {...form.props}>"
},
{
"type": "AddedLine",
"lineAfter": 228,
"content": "\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 229,
"content": "\t\t\t\t\t\tlabelProps={{ children: 'New Email' }}"
},
{
"type": "AddedLine",
"lineAfter": 230,
"content": "\t\t\t\t\t\tinputProps={conform.input(fields.email)}"
},
{
"type": "AddedLine",
"lineAfter": 231,
"content": "\t\t\t\t\t\terrors={fields.email.errors}"
},
{
"type": "AddedLine",
"lineAfter": 232,
"content": "\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 233,
"content": "\t\t\t\t\t<ErrorList id={form.errorId} errors={form.errors} />"
},
{
"type": "AddedLine",
"lineAfter": 234,
"content": "\t\t\t\t\t<div>"
},
{
"type": "AddedLine",
"lineAfter": 235,
"content": "\t\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 236,
"content": "\t\t\t\t\t\t\tstatus={isPending ? 'pending' : actionData?.status ?? 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 237,
"content": "\t\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 238,
"content": "\t\t\t\t\t\t\tSend Confirmation"
},
{
"type": "AddedLine",
"lineAfter": 239,
"content": "\t\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 240,
"content": "\t\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 241,
"content": "\t\t\t\t</Form>"
},
{
"type": "AddedLine",
"lineAfter": 242,
"content": "\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 243,
"content": "\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 244,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 245,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.change-email.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 203
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "\tjson,"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "\ttype DataFunctionArgs,"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "\ttype SerializeFrom,"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "} from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "import { Form, useFetcher, useLoaderData } from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "import { Icon } from '~/components/ui/icon.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "import {"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "\tTooltip,"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\tTooltipContent,"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\tTooltipProvider,"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "\tTooltipTrigger,"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "} from '~/components/ui/tooltip.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "import { requireUserId } from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "import { invariantResponse, useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "import { createToastHeaders } from '~/utils/toast.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "export const handle = {"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "\tbreadcrumb: <Icon name=\"link-2\">Connections</Icon>,"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "const GitHubUserSchema = z.object({"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\tlogin: z.string(),"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "})"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "async function userCanDeleteConnections(userId: string) {"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\tconst user = await prisma.user.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\t\tselect: {"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "\t\t\tpassword: { select: { userId: true } },"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "\t\t\t_count: { select: { gitHubConnections: true } },"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\t\twhere: { id: userId },"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t// user can delete their connections if they have a password"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\tif (user?.password) return true"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t// users have to have more than one remaining connection to delete one"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\treturn Boolean("
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t\tuser?._count.gitHubConnections && user?._count.gitHubConnections > 1,"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\tconst rawGitHubConnections = await prisma.gitHubConnection.findMany({"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\t\tselect: { id: true, providerId: true, createdAt: true },"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\t\twhere: { userId },"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\tconst githubConnections: Array<{"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\tid: string"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t\tusername: string"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t\tcreatedAtFormatted: string"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\t}> = []"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\tfor (const connection of rawGitHubConnections) {"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\tconst response = await fetch("
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t\t\t`https://api.github.com/user/${connection.providerId}`,"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\t\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t\t\t\theaders: {"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t\t\t\t\tAuthorization: `token ${process.env.GITHUB_TOKEN}`,"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t\t)"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\t\tconst rawJson = await response.json()"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\t\tconst result = GitHubUserSchema.safeParse(rawJson)"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\t\tgithubConnections.push({"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\t\t\tid: connection.id,"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\t\t\tusername: result.success ? result.data.login : 'Unknown',"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\t\t\tcreatedAtFormatted: connection.createdAt.toLocaleString(),"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\treturn json({"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\t\tgithubConnections,"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "\t\tcanDeleteConnections: await userCanDeleteConnections(userId),"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\tconst formData = await request.formData()"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\tinvariantResponse("
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "\t\tformData.get('intent') === 'delete-connection',"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\t\t'Invalid intent',"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\tinvariantResponse("
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\t\tawait userCanDeleteConnections(userId),"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "\t\t'You cannot delete your last connection unless you have a password.',"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\tconst connectionId = formData.get('connectionId')"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\tinvariantResponse(typeof connectionId === 'string', 'Invalid connectionId')"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\tawait prisma.gitHubConnection.delete({"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\t\twhere: {"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t\t\tid: connectionId,"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "\t\t\tuserId: userId,"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "\tconst toastHeaders = await createToastHeaders({"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\t\ttitle: 'Deleted',"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t\tdescription: 'Your connection has been deleted.',"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": "\treturn json({ status: 'success' } as const, { headers: toastHeaders })"
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "export default function Connections() {"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\tconst data = useLoaderData<typeof loader>()"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "\tconst isGitHubSubmitting = useIsPending({ formAction: '/auth/github' })"
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "\t\t<div className=\"max-w-md mx-auto\">"
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": "\t\t\t{data.githubConnections.length ? ("
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "\t\t\t\t<div className=\"flex gap-2 flex-col\">"
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": "\t\t\t\t\t<p>Here are your current connections:</p>"
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": "\t\t\t\t\t<ul className=\"flex flex-col gap-4\">"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "\t\t\t\t\t\t{data.githubConnections.map(c => ("
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "\t\t\t\t\t\t\t<li key={c.id}>"
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": "\t\t\t\t\t\t\t\t<Connection"
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\t\t\t\t\t\t\t\t\tconnection={c}"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\t\t\t\t\t\t\t\t\tcanDelete={data.canDeleteConnections}"
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\t\t\t\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "\t\t\t\t\t\t\t</li>"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\t\t\t\t\t\t))}"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\t\t\t\t\t</ul>"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t\t\t) : ("
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": "\t\t\t\t<p>You don't have any connections yet.</p>"
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "\t\t\t)}"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "\t\t\t<Form"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\t\t\t\tclassName=\"mt-5 flex items-center justify-center gap-2 border-t-2 border-border pt-3\""
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": "\t\t\t\taction=\"/auth/github\""
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\t\t\t\tmethod=\"POST\""
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": "\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": "\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\t\t\t\t\tstatus={isGitHubSubmitting ? 'pending' : 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": "\t\t\t\t\t<Icon name=\"github-logo\">Connect with GitHub</Icon>"
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\t\t\t</Form>"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "function Connection({"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": "\tconnection,"
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\tcanDelete,"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "}: {"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "\tconnection: SerializeFrom<typeof loader>['githubConnections'][number]"
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "\tcanDelete: boolean"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "}) {"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "\tconst deleteFetcher = useFetcher<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "\t\t<div className=\"flex gap-2 justify-between\">"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "\t\t\t<Icon name=\"github-logo\">"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\t\t\t\t<a"
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "\t\t\t\t\thref={`https://github.com/${connection.username}`}"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": "\t\t\t\t\tclassName=\"underline\""
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": "\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": "\t\t\t\t\t{connection.username}"
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": "\t\t\t\t</a>{' '}"
},
{
"type": "AddedLine",
"lineAfter": 163,
"content": "\t\t\t\t({connection.createdAtFormatted})"
},
{
"type": "AddedLine",
"lineAfter": 164,
"content": "\t\t\t</Icon>"
},
{
"type": "AddedLine",
"lineAfter": 165,
"content": "\t\t\t{canDelete ? ("
},
{
"type": "AddedLine",
"lineAfter": 166,
"content": "\t\t\t\t<deleteFetcher.Form method=\"POST\">"
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": "\t\t\t\t\t<input name=\"connectionId\" value={connection.id} type=\"hidden\" />"
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": "\t\t\t\t\t<TooltipProvider>"
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": "\t\t\t\t\t\t<Tooltip>"
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": "\t\t\t\t\t\t\t<TooltipTrigger asChild>"
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "\t\t\t\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "\t\t\t\t\t\t\t\t\tname=\"intent\""
},
{
"type": "AddedLine",
"lineAfter": 173,
"content": "\t\t\t\t\t\t\t\t\tvalue=\"delete-connection\""
},
{
"type": "AddedLine",
"lineAfter": 174,
"content": "\t\t\t\t\t\t\t\t\tvariant=\"destructive\""
},
{
"type": "AddedLine",
"lineAfter": 175,
"content": "\t\t\t\t\t\t\t\t\tsize=\"sm\""
},
{
"type": "AddedLine",
"lineAfter": 176,
"content": "\t\t\t\t\t\t\t\t\tstatus={"
},
{
"type": "AddedLine",
"lineAfter": 177,
"content": "\t\t\t\t\t\t\t\t\t\tdeleteFetcher.state !== 'idle'"
},
{
"type": "AddedLine",
"lineAfter": 178,
"content": "\t\t\t\t\t\t\t\t\t\t\t? 'pending'"
},
{
"type": "AddedLine",
"lineAfter": 179,
"content": "\t\t\t\t\t\t\t\t\t\t\t: deleteFetcher.data?.status ?? 'idle'"
},
{
"type": "AddedLine",
"lineAfter": 180,
"content": "\t\t\t\t\t\t\t\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 181,
"content": "\t\t\t\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 182,
"content": "\t\t\t\t\t\t\t\t\t<Icon name=\"cross-1\" />"
},
{
"type": "AddedLine",
"lineAfter": 183,
"content": "\t\t\t\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 184,
"content": "\t\t\t\t\t\t\t</TooltipTrigger>"
},
{
"type": "AddedLine",
"lineAfter": 185,
"content": "\t\t\t\t\t\t\t<TooltipContent>Disconnect this account</TooltipContent>"
},
{
"type": "AddedLine",
"lineAfter": 186,
"content": "\t\t\t\t\t\t</Tooltip>"
},
{
"type": "AddedLine",
"lineAfter": 187,
"content": "\t\t\t\t\t</TooltipProvider>"
},
{
"type": "AddedLine",
"lineAfter": 188,
"content": "\t\t\t\t</deleteFetcher.Form>"
},
{
"type": "AddedLine",
"lineAfter": 189,
"content": "\t\t\t) : ("
},
{
"type": "AddedLine",
"lineAfter": 190,
"content": "\t\t\t\t<TooltipProvider>"
},
{
"type": "AddedLine",
"lineAfter": 191,
"content": "\t\t\t\t\t<Tooltip>"
},
{
"type": "AddedLine",
"lineAfter": 192,
"content": "\t\t\t\t\t\t<TooltipTrigger>"
},
{
"type": "AddedLine",
"lineAfter": 193,
"content": "\t\t\t\t\t\t\t<Icon name=\"question-mark-circled\"></Icon>"
},
{
"type": "AddedLine",
"lineAfter": 194,
"content": "\t\t\t\t\t\t</TooltipTrigger>"
},
{
"type": "AddedLine",
"lineAfter": 195,
"content": "\t\t\t\t\t\t<TooltipContent>"
},
{
"type": "AddedLine",
"lineAfter": 196,
"content": "\t\t\t\t\t\t\tYou cannot delete your last connection unless you have a password."
},
{
"type": "AddedLine",
"lineAfter": 197,
"content": "\t\t\t\t\t\t</TooltipContent>"
},
{
"type": "AddedLine",
"lineAfter": 198,
"content": "\t\t\t\t\t</Tooltip>"
},
{
"type": "AddedLine",
"lineAfter": 199,
"content": "\t\t\t\t</TooltipProvider>"
},
{
"type": "AddedLine",
"lineAfter": 200,
"content": "\t\t\t)}"
},
{
"type": "AddedLine",
"lineAfter": 201,
"content": "\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 202,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 203,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.connections.tsx"
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "import {",
"type": "Chunk",
"toFileRange": {
"start": 14,
"lines": 18
},
"fromFileRange": {
"start": 14,
"lines": 22
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 14,
"lineAfter": 14,
"content": "\tinvariantResponse,"
},
{
"type": "UnchangedLine",
"lineBefore": 15,
"lineAfter": 15,
"content": "\tuseDoubleCheck,"
},
{
"type": "UnchangedLine",
"lineBefore": 16,
"lineAfter": 16,
"content": "} from '~/utils/misc.tsx'"
},
{
"type": "DeletedLine",
"lineBefore": 17,
"content": "import { getSession } from '~/utils/session.server.ts'"
},
{
"type": "DeletedLine",
"lineBefore": 18,
"content": "import {"
},
{
"type": "DeletedLine",
"lineBefore": 19,
"content": "\temailSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 20,
"content": "\tnameSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 21,
"content": "\tusernameSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 22,
"content": "} from '~/utils/user-validation.ts'"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "import { sessionStorage } from '~/utils/session.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "import { nameSchema, usernameSchema } from '~/utils/user-validation.ts'"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "import { twoFAVerificationType } from './profile.two-factor.tsx'"
},
{
"type": "UnchangedLine",
"lineBefore": 23,
"lineAfter": 20,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 24,
"lineAfter": 21,
"content": "const ProfileFormSchema = z.object({"
},
{
"type": "UnchangedLine",
"lineBefore": 25,
"lineAfter": 22,
"content": "\tname: nameSchema.optional(),"
},
{
"type": "UnchangedLine",
"lineBefore": 26,
"lineAfter": 23,
"content": "\tusername: usernameSchema,"
},
{
"type": "DeletedLine",
"lineBefore": 27,
"content": "\temail: emailSchema,"
},
{
"type": "UnchangedLine",
"lineBefore": 28,
"lineAfter": 24,
"content": "})"
},
{
"type": "UnchangedLine",
"lineBefore": 29,
"lineAfter": 25,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 30,
"lineAfter": 26,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "UnchangedLine",
"lineBefore": 31,
"lineAfter": 27,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "DeletedLine",
"lineBefore": 32,
"content": "\tconst user = await prisma.user.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\tconst user = await prisma.user.findUniqueOrThrow({"
},
{
"type": "UnchangedLine",
"lineBefore": 33,
"lineAfter": 29,
"content": "\t\twhere: { id: userId },"
},
{
"type": "UnchangedLine",
"lineBefore": 34,
"lineAfter": 30,
"content": "\t\tselect: {"
},
{
"type": "UnchangedLine",
"lineBefore": 35,
"lineAfter": 31,
"content": "\t\t\tid: true,"
}
]
},
{
"context": "export async function loader({ request }: DataFunctionArgs) {",
"type": "Chunk",
"toFileRange": {
"start": 36,
"lines": 32
},
"fromFileRange": {
"start": 40,
"lines": 14
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 40,
"lineAfter": 36,
"content": "\t\t\t\tselect: { id: true },"
},
{
"type": "UnchangedLine",
"lineBefore": 41,
"lineAfter": 37,
"content": "\t\t\t},"
},
{
"type": "UnchangedLine",
"lineBefore": 42,
"lineAfter": 38,
"content": "\t\t\t_count: {"
},
{
"type": "DeletedLine",
"lineBefore": 43,
"content": "\t\t\t\tselect: { sessions: true },"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t\t\t\tselect: {"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t\t\t\t\tsessions: {"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t\t\t\t\t\twhere: {"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t\t\t\t\t\t\texpirationDate: { gt: new Date() },"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\t\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t\t\t\t},"
},
{
"type": "UnchangedLine",
"lineBefore": 44,
"lineAfter": 46,
"content": "\t\t\t},"
},
{
"type": "UnchangedLine",
"lineBefore": 45,
"lineAfter": 47,
"content": "\t\t},"
},
{
"type": "UnchangedLine",
"lineBefore": 46,
"lineAfter": 48,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 47,
"lineAfter": 49,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 48,
"content": "\tinvariantResponse(user, 'User not found', { status: 404 })"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\tconst twoFactorVerification = await prisma.verification.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t\tselect: { id: true },"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\twhere: { target_type: { type: twoFAVerificationType, target: userId } },"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\tconst password = await prisma.password.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t\tselect: { userId: true },"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\twhere: { userId },"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 49,
"lineAfter": 59,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 50,
"content": "\treturn json({ user })"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\treturn json({"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t\tuser,"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\t\thasPassword: Boolean(password),"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t\tisTwoFactorEnabled: Boolean(twoFactorVerification),"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 51,
"lineAfter": 65,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 52,
"lineAfter": 66,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 53,
"lineAfter": 67,
"content": "type ProfileActionArgs = {"
}
]
},
{
"context": "export default function EditUserProfile() {",
"type": "Chunk",
"toFileRange": {
"start": 126,
"lines": 31
},
"fromFileRange": {
"start": 112,
"lines": 8
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 112,
"lineAfter": 126,
"content": "\t\t\t<div className=\"col-span-6 mb-12 mt-6 h-1 border-b-[1.5px]\" />"
},
{
"type": "UnchangedLine",
"lineBefore": 113,
"lineAfter": 127,
"content": "\t\t\t<div className=\"col-span-full flex flex-col gap-6\">"
},
{
"type": "UnchangedLine",
"lineBefore": 114,
"lineAfter": 128,
"content": "\t\t\t\t<div>"
},
{
"type": "DeletedLine",
"lineBefore": 115,
"content": "\t\t\t\t\t<Link to=\"password\">"
},
{
"type": "DeletedLine",
"lineBefore": 116,
"content": "\t\t\t\t\t\t<Icon name=\"dots-horizontal\">Change Password</Icon>"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "\t\t\t\t\t<Link to=\"change-email\">"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\t\t\t\t\t\t<Icon name=\"envelope-closed\">"
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": "\t\t\t\t\t\t\tChange email from {data.user.email}"
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\t\t\t\t\t\t</Icon>"
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": "\t\t\t\t\t</Link>"
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\t\t\t\t<div>"
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": "\t\t\t\t\t<Link to=\"two-factor\">"
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\t\t\t\t\t\t{data.isTwoFactorEnabled ? ("
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "\t\t\t\t\t\t\t<Icon name=\"lock-closed\">2FA is enabled</Icon>"
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": "\t\t\t\t\t\t) : ("
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "\t\t\t\t\t\t\t<Icon name=\"lock-open-1\">Enable 2FA</Icon>"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\t\t\t\t\t\t)}"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\t\t\t\t\t</Link>"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "\t\t\t\t<div>"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": "\t\t\t\t\t<Link to={data.hasPassword ? 'password' : 'password/create'}>"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "\t\t\t\t\t\t<Icon name=\"dots-horizontal\">"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": "\t\t\t\t\t\t\t{data.hasPassword ? 'Change Password' : 'Create a Password'}"
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\t\t\t\t\t\t</Icon>"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "\t\t\t\t\t</Link>"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "\t\t\t\t<div>"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "\t\t\t\t\t<Link to=\"connections\">"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "\t\t\t\t\t\t<Icon name=\"link-2\">Manage connections</Icon>"
},
{
"type": "UnchangedLine",
"lineBefore": 117,
"lineAfter": 154,
"content": "\t\t\t\t\t</Link>"
},
{
"type": "UnchangedLine",
"lineBefore": 118,
"lineAfter": 155,
"content": "\t\t\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 119,
"lineAfter": 156,
"content": "\t\t\t\t<div>"
}
]
},
{
"context": "export default function EditUserProfile() {",
"type": "Chunk",
"toFileRange": {
"start": 171,
"lines": 7
},
"fromFileRange": {
"start": 134,
"lines": 7
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 134,
"lineAfter": 171,
"content": "async function profileUpdateAction({ userId, formData }: ProfileActionArgs) {"
},
{
"type": "UnchangedLine",
"lineBefore": 135,
"lineAfter": 172,
"content": "\tconst submission = await parse(formData, {"
},
{
"type": "UnchangedLine",
"lineBefore": 136,
"lineAfter": 173,
"content": "\t\tasync: true,"
},
{
"type": "DeletedLine",
"lineBefore": 137,
"content": "\t\tschema: ProfileFormSchema.superRefine(async ({ email, username }, ctx) => {"
},
{
"type": "AddedLine",
"lineAfter": 174,
"content": "\t\tschema: ProfileFormSchema.superRefine(async ({ username }, ctx) => {"
},
{
"type": "UnchangedLine",
"lineBefore": 138,
"lineAfter": 175,
"content": "\t\t\tconst existingUsername = await prisma.user.findUnique({"
},
{
"type": "UnchangedLine",
"lineBefore": 139,
"lineAfter": 176,
"content": "\t\t\t\twhere: { username },"
},
{
"type": "UnchangedLine",
"lineBefore": 140,
"lineAfter": 177,
"content": "\t\t\t\tselect: { id: true },"
}
]
},
{
"context": "async function profileUpdateAction({ userId, formData }: ProfileActionArgs) {",
"type": "Chunk",
"toFileRange": {
"start": 183,
"lines": 6
},
"fromFileRange": {
"start": 146,
"lines": 17
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 146,
"lineAfter": 183,
"content": "\t\t\t\t\tmessage: 'A user already exists with this username',"
},
{
"type": "UnchangedLine",
"lineBefore": 147,
"lineAfter": 184,
"content": "\t\t\t\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 148,
"lineAfter": 185,
"content": "\t\t\t}"
},
{
"type": "DeletedLine",
"lineBefore": 149,
"content": "\t\t\tconst existingEmail = await prisma.user.findUnique({"
},
{
"type": "DeletedLine",
"lineBefore": 150,
"content": "\t\t\t\twhere: { email },"
},
{
"type": "DeletedLine",
"lineBefore": 151,
"content": "\t\t\t\tselect: { id: true },"
},
{
"type": "DeletedLine",
"lineBefore": 152,
"content": "\t\t\t})"
},
{
"type": "DeletedLine",
"lineBefore": 153,
"content": "\t\t\tif (existingEmail && existingEmail.id !== userId) {"
},
{
"type": "DeletedLine",
"lineBefore": 154,
"content": "\t\t\t\tctx.addIssue({"
},
{
"type": "DeletedLine",
"lineBefore": 155,
"content": "\t\t\t\t\tpath: ['email'],"
},
{
"type": "DeletedLine",
"lineBefore": 156,
"content": "\t\t\t\t\tcode: 'custom',"
},
{
"type": "DeletedLine",
"lineBefore": 157,
"content": "\t\t\t\t\tmessage: 'A user already exists with this email',"
},
{
"type": "DeletedLine",
"lineBefore": 158,
"content": "\t\t\t\t})"
},
{
"type": "DeletedLine",
"lineBefore": 159,
"content": "\t\t\t}"
},
{
"type": "UnchangedLine",
"lineBefore": 160,
"lineAfter": 186,
"content": "\t\t}),"
},
{
"type": "UnchangedLine",
"lineBefore": 161,
"lineAfter": 187,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 162,
"lineAfter": 188,
"content": "\tif (submission.intent !== 'submit') {"
}
]
},
{
"context": "async function profileUpdateAction({ userId, formData }: ProfileActionArgs) {",
"type": "Chunk",
"toFileRange": {
"start": 200,
"lines": 6
},
"fromFileRange": {
"start": 174,
"lines": 7
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 174,
"lineAfter": 200,
"content": "\t\tdata: {"
},
{
"type": "UnchangedLine",
"lineBefore": 175,
"lineAfter": 201,
"content": "\t\t\tname: data.name,"
},
{
"type": "UnchangedLine",
"lineBefore": 176,
"lineAfter": 202,
"content": "\t\t\tusername: data.username,"
},
{
"type": "DeletedLine",
"lineBefore": 177,
"content": "\t\t\temail: data.email,"
},
{
"type": "UnchangedLine",
"lineBefore": 178,
"lineAfter": 203,
"content": "\t\t},"
},
{
"type": "UnchangedLine",
"lineBefore": 179,
"lineAfter": 204,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 180,
"lineAfter": 205,
"content": ""
}
]
},
{
"context": "function UpdateProfile() {",
"type": "Chunk",
"toFileRange": {
"start": 243,
"lines": 6
},
"fromFileRange": {
"start": 218,
"lines": 12
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 218,
"lineAfter": 243,
"content": "\t\t\t\t\tinputProps={conform.input(fields.name)}"
},
{
"type": "UnchangedLine",
"lineBefore": 219,
"lineAfter": 244,
"content": "\t\t\t\t\terrors={fields.name.errors}"
},
{
"type": "UnchangedLine",
"lineBefore": 220,
"lineAfter": 245,
"content": "\t\t\t\t/>"
},
{
"type": "DeletedLine",
"lineBefore": 221,
"content": "\t\t\t\t<Field"
},
{
"type": "DeletedLine",
"lineBefore": 222,
"content": "\t\t\t\t\tclassName=\"col-span-3\""
},
{
"type": "DeletedLine",
"lineBefore": 223,
"content": "\t\t\t\t\tlabelProps={{ htmlFor: fields.email.id, children: 'Email' }}"
},
{
"type": "DeletedLine",
"lineBefore": 224,
"content": "\t\t\t\t\tinputProps={conform.input(fields.email)}"
},
{
"type": "DeletedLine",
"lineBefore": 225,
"content": "\t\t\t\t\terrors={fields.email.errors}"
},
{
"type": "DeletedLine",
"lineBefore": 226,
"content": "\t\t\t\t/>"
},
{
"type": "UnchangedLine",
"lineBefore": 227,
"lineAfter": 246,
"content": "\t\t\t</div>"
},
{
"type": "UnchangedLine",
"lineBefore": 228,
"lineAfter": 247,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 229,
"lineAfter": 248,
"content": "\t\t\t<ErrorList errors={form.errors} id={form.errorId} />"
}
]
},
{
"context": "function UpdateProfile() {",
"type": "Chunk",
"toFileRange": {
"start": 267,
"lines": 9
},
"fromFileRange": {
"start": 248,
"lines": 7
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 248,
"lineAfter": 267,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 249,
"lineAfter": 268,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 250,
"lineAfter": 269,
"content": "async function signOutOfSessionsAction({ request, userId }: ProfileActionArgs) {"
},
{
"type": "DeletedLine",
"lineBefore": 251,
"content": "\tconst cookieSession = await getSession(request.headers.get('cookie'))"
},
{
"type": "AddedLine",
"lineAfter": 270,
"content": "\tconst cookieSession = await sessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 271,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 272,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 252,
"lineAfter": 273,
"content": "\tconst sessionId = cookieSession.get(sessionKey)"
},
{
"type": "UnchangedLine",
"lineBefore": 253,
"lineAfter": 274,
"content": "\tinvariantResponse("
},
{
"type": "UnchangedLine",
"lineBefore": 254,
"lineAfter": 275,
"content": "\t\tsessionId,"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.index.tsx"
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "const ChangePasswordForm = z",
"type": "Chunk",
"toFileRange": {
"start": 36,
"lines": 25
},
"fromFileRange": {
"start": 36,
"lines": 8
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 36,
"lineAfter": 36,
"content": "\t\t}"
},
{
"type": "UnchangedLine",
"lineBefore": 37,
"lineAfter": 37,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 38,
"lineAfter": 38,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "async function requirePassword(userId: string) {"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\tconst password = await prisma.password.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t\tselect: { userId: true },"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t\twhere: { userId },"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\tif (!password) {"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t\tthrow redirect('/settings/profile/password/create')"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\tawait requirePassword(userId)"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\treturn json({})"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 39,
"lineAfter": 55,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "UnchangedLine",
"lineBefore": 40,
"lineAfter": 56,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\tawait requirePassword(userId)"
},
{
"type": "UnchangedLine",
"lineBefore": 41,
"lineAfter": 58,
"content": "\tconst formData = await request.formData()"
},
{
"type": "UnchangedLine",
"lineBefore": 42,
"lineAfter": 59,
"content": "\tconst submission = await parse(formData, {"
},
{
"type": "UnchangedLine",
"lineBefore": 43,
"lineAfter": 60,
"content": "\t\tasync: true,"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.password.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 128
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { conform, useForm } from '@conform-to/react'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { getFieldsetConstraint, parse } from '@conform-to/zod'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import { json, redirect, type DataFunctionArgs } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "import { Form, Link, useActionData } from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "import { ErrorList, Field } from '~/components/forms.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "import { Button } from '~/components/ui/button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "import { Icon } from '~/components/ui/icon.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "import { getPasswordHash, requireUserId } from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "import { useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "import { passwordSchema } from '~/utils/user-validation.ts'"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "export const handle = {"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "\tbreadcrumb: <Icon name=\"dots-horizontal\">Password</Icon>,"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "const CreatePasswordForm = z"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "\t.object({"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "\t\tnewPassword: passwordSchema,"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "\t\tconfirmNewPassword: passwordSchema,"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "\t.superRefine(({ confirmNewPassword, newPassword }, ctx) => {"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "\t\tif (confirmNewPassword !== newPassword) {"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\t\t\tctx.addIssue({"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "\t\t\t\tpath: ['confirmNewPassword'],"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\t\t\t\tcode: 'custom',"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\t\t\t\tmessage: 'The passwords must match',"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "async function requireNoPassword(userId: string) {"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\tconst password = await prisma.password.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\t\tselect: { userId: true },"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t\twhere: { userId },"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\tif (password) {"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t\tthrow redirect('/settings/profile/password')"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\tawait requireNoPassword(userId)"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\treturn json({})"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\tawait requireNoPassword(userId)"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\tconst formData = await request.formData()"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\tconst submission = await parse(formData, {"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\t\tasync: true,"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t\tschema: CreatePasswordForm,"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t// clear the payload so we don't send the password back to the client"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\tsubmission.payload = {}"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\tif (submission.intent !== 'submit') {"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t\t// clear the value so we don't send the password back to the client"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\t\tsubmission.value = undefined"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t\treturn json({ status: 'idle', submission } as const)"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\tif (!submission.value) {"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\tconst { newPassword } = submission.value"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\tawait prisma.user.update({"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t\tselect: { username: true },"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\t\twhere: { id: userId },"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\t\tdata: {"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\t\t\tpassword: {"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "\t\t\t\tcreate: {"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\t\t\t\t\thash: await getPasswordHash(newPassword),"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\treturn redirect(`/settings/profile`, { status: 302 })"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "export default function CreatePasswordRoute() {"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\tconst actionData = useActionData<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\tconst isPending = useIsPending()"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\tconst [form, fields] = useForm({"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\t\tid: 'signup-form',"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\t\tconstraint: getFieldsetConstraint(CreatePasswordForm),"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\t\tlastSubmission: actionData?.submission,"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\t\tonValidate({ formData }) {"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t\t\treturn parse(formData, { schema: CreatePasswordForm })"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\t\tshouldRevalidate: 'onBlur',"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t\t<Form method=\"POST\" {...form.props} className=\"max-w-md mx-auto\">"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": "\t\t\t\tlabelProps={{ children: 'New Password' }}"
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "\t\t\t\tinputProps={conform.input(fields.newPassword, { type: 'password' })}"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": "\t\t\t\terrors={fields.newPassword.errors}"
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "\t\t\t\tlabelProps={{ children: 'Confirm New Password' }}"
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": "\t\t\t\tinputProps={conform.input(fields.confirmNewPassword, {"
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "\t\t\t\t\ttype: 'password',"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "\t\t\t\t})}"
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": "\t\t\t\terrors={fields.confirmNewPassword.errors}"
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": "\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": "\t\t\t<ErrorList id={form.errorId} errors={form.errors} />"
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": "\t\t\t<div className=\"w-full grid grid-cols-2 gap-6\">"
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "\t\t\t\t<Button variant=\"secondary\" asChild>"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "\t\t\t\t\t<Link to=\"..\">Cancel</Link>"
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": "\t\t\t\t</Button>"
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\t\t\t\t\tstatus={isPending ? 'pending' : actionData?.status ?? 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\t\t\t\t\tCreate Password"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t\t</Form>"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.password_.create.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 59
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { json, type DataFunctionArgs } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { useFetcher } from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import { Icon } from '~/components/ui/icon.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "import { requireRecentVerification } from '~/routes/_auth+/verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "import { requireUserId } from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "import { useDoubleCheck } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import { redirectWithToast } from '~/utils/toast.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "import { twoFAVerificationType } from './profile.two-factor.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "export const handle = {"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\tbreadcrumb: <Icon name=\"lock-open-1\">Disable</Icon>,"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "\tawait requireRecentVerification(request)"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "\treturn json({})"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "\tawait requireRecentVerification(request)"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "\tawait prisma.verification.delete({"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "\t\twhere: { target_type: { target: userId, type: twoFAVerificationType } },"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "\treturn redirectWithToast('/settings/profile/two-factor', {"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\t\ttitle: '2FA Disabled',"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\t\tdescription: 'Two factor authentication has been disabled.',"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "export default function TwoFactorDisableRoute() {"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\tconst disable2FAFetcher = useFetcher<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\tconst dc = useDoubleCheck()"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t\t<div className=\"mx-auto max-w-sm\">"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t\t\t<disable2FAFetcher.Form method=\"POST\" preventScrollReset>"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t\t\t\t\tDisabling two factor authentication is not recommended. However, if"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t\t\t\t\tyou would like to do so, click here:"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t\t\t\t\tvariant=\"destructive\""
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\t\t\t\t\tstatus={disable2FAFetcher.state === 'loading' ? 'pending' : 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\t\t\t\t\t{...dc.getButtonProps({"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\t\t\t\t\t\tclassName: 'mx-auto',"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\t\t\t\t\t\tname: 'intent',"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\t\t\t\t\t\tvalue: 'disable',"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t\t\t\t\t\ttype: 'submit',"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\t\t\t\t})}"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t\t\t\t\t{dc.doubleCheck ? 'Are you sure?' : 'Disable 2FA'}"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t\t\t</disable2FAFetcher.Form>"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.disable.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 86
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { generateTOTP } from '@epic-web/totp'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { json, redirect, type DataFunctionArgs } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import { Link, useFetcher, useLoaderData } from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "import { Icon } from '~/components/ui/icon.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "import { requireUserId } from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "import { twoFAVerificationType } from './profile.two-factor.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import { twoFAVerifyVerificationType as twoFAVerifyVerificationType } from './profile.two-factor.verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\tconst verification = await prisma.verification.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "\t\twhere: { target_type: { type: twoFAVerificationType, target: userId } },"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "\t\tselect: { id: true },"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "\treturn json({ is2FAEnabled: Boolean(verification) })"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "\tconst { otp: _otp, ...config } = generateTOTP()"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "\tconst verificationData = {"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "\t\t...config,"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "\t\ttype: twoFAVerifyVerificationType,"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\t\ttarget: userId,"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\tawait prisma.verification.upsert({"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\t\twhere: {"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\t\t\ttarget_type: { target: userId, type: twoFAVerifyVerificationType },"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "\t\tcreate: verificationData,"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "\t\tupdate: verificationData,"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\treturn redirect('/settings/profile/two-factor/verify')"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "export default function TwoFactorRoute() {"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\tconst data = useLoaderData<typeof loader>()"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\tconst enable2FAFetcher = useFetcher<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\t<div className=\"flex flex-col gap-4\">"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t\t\t{data.is2FAEnabled ? ("
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t\t\t\t<>"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\t\t\t\t\t<p className=\"text-lg\">"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\t\t\t\t\t\t<Icon name=\"check\">"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\t\t\t\t\t\t\tYou have enabled two-factor authentication."
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\t\t\t\t\t\t</Icon>"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\t\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t\t\t\t\t<Link to=\"disable\">"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\t\t\t\t\t<Icon name=\"lock-open-1\">Disable 2FA</Icon>"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t\t\t\t\t</Link>"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t\t\t\t</>"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\t\t\t) : ("
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t\t\t\t<>"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\t\t\t\t<p>"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t\t\t\t\t\t<Icon name=\"lock-open-1\">"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\t\t\t\t\t\t\tYou have not enabled two-factor authentication yet."
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t\t\t\t\t\t</Icon>"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\t\t\t\t\t<p className=\"text-sm\">"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t\t\t\t\t\tTwo factor authentication adds an extra layer of security to your"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t\t\t\t\t\taccount. You will need to enter a code from an authenticator app"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\t\t\t\t\t\tlike{' '}"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\t\t\t\t\t\t<a className=\"underline\" href=\"https://1password.com/\">"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\t\t\t\t\t\t\t1Password"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\t\t\t\t\t\t</a>{' '}"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\t\t\t\t\t\tto log in."
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\t\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\t\t\t\t\t<enable2FAFetcher.Form method=\"POST\" preventScrollReset>"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\t\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\t\t\t\t\t\t\tname=\"intent\""
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\t\t\t\t\t\t\tvalue=\"enable\""
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "\t\t\t\t\t\t\tstatus={enable2FAFetcher.state === 'loading' ? 'pending' : 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\t\t\t\t\t\t\tclassName=\"mx-auto\""
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\t\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "\t\t\t\t\t\t\tEnable 2FA"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\t\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\t\t\t\t</enable2FAFetcher.Form>"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t\t\t\t</>"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\t\t\t)}"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.index.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 13
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { Outlet } from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { Icon } from '~/components/ui/icon.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import { type VerificationTypes } from '~/routes/_auth+/verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "export const handle = {"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\tbreadcrumb: <Icon name=\"lock-closed\">2FA</Icon>,"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "export const twoFAVerificationType = '2fa' satisfies VerificationTypes"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "export default function TwoFactorRoute() {"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\treturn <Outlet />"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 172
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { conform, useForm } from '@conform-to/react'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { getFieldsetConstraint, parse } from '@conform-to/zod'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import { getTOTPAuthUri } from '@epic-web/totp'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "import { json, redirect, type DataFunctionArgs } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "import { Form, useActionData, useLoaderData } from '@remix-run/react'"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "import * as QRCode from 'qrcode'"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "import { Field } from '~/components/forms.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import { Icon } from '~/components/ui/icon.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "import { isCodeValid } from '~/routes/_auth+/verify.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "import { requireUserId } from '~/utils/auth.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "import { getDomainUrl, useIsPending } from '~/utils/misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "import { redirectWithToast } from '~/utils/toast.server.ts'"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "import { twoFAVerificationType } from './profile.two-factor.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "export const handle = {"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "\tbreadcrumb: <Icon name=\"check\">Verify</Icon>,"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "const VerifySchema = z.object({"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "\tcode: z.string().min(6).max(6),"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "})"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "export const twoFAVerifyVerificationType = '2fa-verify'"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "export async function loader({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\tconst verification = await prisma.verification.findUnique({"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\t\twhere: {"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "\t\t\ttarget_type: { type: twoFAVerifyVerificationType, target: userId },"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\t\tselect: {"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\t\t\tid: true,"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\t\t\talgorithm: true,"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t\t\tsecret: true,"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t\t\tperiod: true,"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t\t\tdigits: true,"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\tif (!verification) {"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\treturn redirect('/settings/profile/two-factor')"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\tconst user = await prisma.user.findUniqueOrThrow({"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\t\twhere: { id: userId },"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\t\tselect: { email: true },"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\tconst issuer = new URL(getDomainUrl(request)).host"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\tconst otpUri = getTOTPAuthUri({"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t\t...verification,"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": "\t\taccountName: user.email,"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\t\tissuer,"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\tconst qrCode = await QRCode.toDataURL(otpUri)"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\treturn json({ otpUri, qrCode })"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "export async function action({ request }: DataFunctionArgs) {"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\tconst userId = await requireUserId(request)"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\tconst formData = await request.formData()"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\tif (formData.get('intent') === 'cancel') {"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t\tawait prisma.verification.deleteMany({"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\t\t\twhere: { type: twoFAVerifyVerificationType, target: userId },"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\t\treturn redirect('/settings/profile/two-factor')"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\tconst submission = await parse(formData, {"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\t\tschema: () =>"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\t\t\tVerifySchema.superRefine(async (data, ctx) => {"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t\t\t\tconst codeIsValid = await isCodeValid({"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\t\t\t\t\tcode: data.code,"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\t\t\t\t\ttype: twoFAVerifyVerificationType,"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\t\t\t\t\ttarget: userId,"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "\t\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "\t\t\t\tif (!codeIsValid) {"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\t\t\t\t\tctx.addIssue({"
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "\t\t\t\t\t\tpath: ['code'],"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\t\t\t\t\t\tcode: z.ZodIssueCode.custom,"
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\t\t\t\t\tmessage: `Invalid code`,"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t\t\t\t\t})"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\t\t\t\t\treturn"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "\t\t\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "\t\t\t}),"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": "\t\tacceptMultipleErrors: () => true,"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": "\t\tasync: true,"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\tif (submission.intent !== 'submit') {"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\t\treturn json({ status: 'idle', submission } as const)"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\tif (!submission.value) {"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\t\treturn json({ status: 'error', submission } as const, { status: 400 })"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\tawait prisma.verification.update({"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\t\twhere: {"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "\t\t\ttarget_type: { type: twoFAVerifyVerificationType, target: userId },"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t\tdata: { type: twoFAVerificationType },"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": "\treturn redirectWithToast('/settings/profile/two-factor', {"
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": "\t\ttype: 'success',"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": "\t\ttitle: 'Enabled',"
},
{
"type": "AddedLine",
"lineAfter": 106,
"content": "\t\tdescription: 'Two-factor authentication has been enabled.',"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 108,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 109,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 110,
"content": "export default function TwoFactorRoute() {"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "\tconst data = useLoaderData<typeof loader>()"
},
{
"type": "AddedLine",
"lineAfter": 112,
"content": "\tconst actionData = useActionData<typeof action>()"
},
{
"type": "AddedLine",
"lineAfter": 113,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 114,
"content": "\tconst isPending = useIsPending()"
},
{
"type": "AddedLine",
"lineAfter": 115,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "\tconst [form, fields] = useForm({"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "\t\tid: 'verify-form',"
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": "\t\tconstraint: getFieldsetConstraint(VerifySchema),"
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "\t\tlastSubmission: actionData?.submission,"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\t\tonValidate({ formData }) {"
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\t\t\treturn parse(formData, { schema: VerifySchema })"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\treturn ("
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t\t<div>"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": "\t\t\t<div className=\"flex flex-col items-center gap-4\">"
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "\t\t\t\t<img alt=\"qr code\" src={data.qrCode} className=\"h-56 w-56\" />"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "\t\t\t\t<p>Scan this QR code with your authenticator app.</p>"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\t\t\t\t<p className=\"text-sm\">"
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": "\t\t\t\t\tIf you cannot scan the QR code, you can manually add this account to"
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\t\t\t\t\tyour authenticator app using this code:"
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "\t\t\t\t<div className=\"p-3\">"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": "\t\t\t\t\t<pre"
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": "\t\t\t\t\t\tclassName=\"whitespace-pre-wrap break-all text-sm\""
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": "\t\t\t\t\t\taria-label=\"One-time Password URI\""
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": "\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": "\t\t\t\t\t\t{data.otpUri}"
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": "\t\t\t\t\t</pre>"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": "\t\t\t\t<p className=\"text-sm\">"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": "\t\t\t\t\tOnce you've added the account, enter the code from your authenticator"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": "\t\t\t\t\tapp below. Once you enable 2FA, you will need to enter a code from"
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": "\t\t\t\t\tyour authenticator app every time you log in or perform important"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": "\t\t\t\t\tactions. Do not lose access to your authenticator app, or you will"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": "\t\t\t\t\tlose access to your account."
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\t\t\t\t</p>"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": "\t\t\t\t<div className=\"flex w-full max-w-xs flex-col justify-center gap-4\">"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "\t\t\t\t\t<Form method=\"POST\" {...form.props} className=\"flex-1\">"
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": "\t\t\t\t\t\t<Field"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "\t\t\t\t\t\t\tlabelProps={{"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "\t\t\t\t\t\t\t\thtmlFor: fields.code.id,"
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": "\t\t\t\t\t\t\t\tchildren: 'Code',"
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "\t\t\t\t\t\t\t}}"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "\t\t\t\t\t\t\tinputProps={{ ...conform.input(fields.code), autoFocus: true }}"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\t\t\t\t\t\t\terrors={fields.code.errors}"
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "\t\t\t\t\t\t/>"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": "\t\t\t\t\t\t<StatusButton"
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": "\t\t\t\t\t\t\tclassName=\"w-full\""
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": "\t\t\t\t\t\t\tstatus={isPending ? 'pending' : actionData?.status ?? 'idle'}"
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": "\t\t\t\t\t\t\ttype=\"submit\""
},
{
"type": "AddedLine",
"lineAfter": 163,
"content": "\t\t\t\t\t\t\tdisabled={isPending}"
},
{
"type": "AddedLine",
"lineAfter": 164,
"content": "\t\t\t\t\t\t>"
},
{
"type": "AddedLine",
"lineAfter": 165,
"content": "\t\t\t\t\t\t\tSubmit"
},
{
"type": "AddedLine",
"lineAfter": 166,
"content": "\t\t\t\t\t\t</StatusButton>"
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": "\t\t\t\t\t</Form>"
},
{
"type": "AddedLine",
"lineAfter": 168,
"content": "\t\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 169,
"content": "\t\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 170,
"content": "\t\t</div>"
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/settings+/profile.two-factor.verify.tsx"
},
{
"type": "ChangedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 6
},
"fromFileRange": {
"start": 1,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 1,
"lineAfter": 1,
"content": "import { useForm } from '@conform-to/react'"
},
{
"type": "UnchangedLine",
"lineBefore": 2,
"lineAfter": 2,
"content": "import { getFieldsetConstraint, parse } from '@conform-to/zod'"
},
{
"type": "DeletedLine",
"lineBefore": 3,
"content": "import { json, redirect, type DataFunctionArgs } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import { json, type DataFunctionArgs } from '@remix-run/node'"
},
{
"type": "UnchangedLine",
"lineBefore": 4,
"lineAfter": 4,
"content": "import {"
},
{
"type": "UnchangedLine",
"lineBefore": 5,
"lineAfter": 5,
"content": "\tForm,"
},
{
"type": "UnchangedLine",
"lineBefore": 6,
"lineAfter": 6,
"content": "\tLink,"
}
]
},
{
"context": "import { ErrorList } from '~/components/forms.tsx'",
"type": "Chunk",
"toFileRange": {
"start": 16,
"lines": 7
},
"fromFileRange": {
"start": 16,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 16,
"lineAfter": 16,
"content": "import { Button } from '~/components/ui/button.tsx'"
},
{
"type": "UnchangedLine",
"lineBefore": 17,
"lineAfter": 17,
"content": "import { Icon } from '~/components/ui/icon.tsx'"
},
{
"type": "UnchangedLine",
"lineBefore": 18,
"lineAfter": 18,
"content": "import { StatusButton } from '~/components/ui/status-button.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "import { requireUserId } from '~/utils/auth.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 19,
"lineAfter": 20,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 20,
"lineAfter": 21,
"content": "import {"
},
{
"type": "UnchangedLine",
"lineBefore": 21,
"lineAfter": 22,
"content": "\tgetNoteImgSrc,"
}
]
},
{
"context": "import {",
"type": "Chunk",
"toFileRange": {
"start": 27,
"lines": 9
},
"fromFileRange": {
"start": 26,
"lines": 9
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 26,
"lineAfter": 27,
"content": "\trequireUserWithPermission,"
},
{
"type": "UnchangedLine",
"lineBefore": 27,
"lineAfter": 28,
"content": "\tuserHasPermission,"
},
{
"type": "UnchangedLine",
"lineBefore": 28,
"lineAfter": 29,
"content": "} from '~/utils/permissions.ts'"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "import { redirectWithToast } from '~/utils/toast.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 29,
"lineAfter": 31,
"content": "import { useOptionalUser } from '~/utils/user.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 30,
"lineAfter": 32,
"content": "import { type loader as notesLoader } from './notes.tsx'"
},
{
"type": "DeletedLine",
"lineBefore": 31,
"content": "import { requireUserId } from '~/utils/auth.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 32,
"lineAfter": 33,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 33,
"lineAfter": 34,
"content": "export async function loader({ params }: DataFunctionArgs) {"
},
{
"type": "UnchangedLine",
"lineBefore": 34,
"lineAfter": 35,
"content": "\tconst note = await prisma.note.findUnique({"
}
]
},
{
"context": "export async function action({ request }: DataFunctionArgs) {",
"type": "Chunk",
"toFileRange": {
"start": 90,
"lines": 16
},
"fromFileRange": {
"start": 89,
"lines": 12
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 89,
"lineAfter": 90,
"content": "\tconst isOwner = note.ownerId === userId"
},
{
"type": "UnchangedLine",
"lineBefore": 90,
"lineAfter": 91,
"content": "\tawait requireUserWithPermission("
},
{
"type": "UnchangedLine",
"lineBefore": 91,
"lineAfter": 92,
"content": "\t\trequest,"
},
{
"type": "DeletedLine",
"lineBefore": 92,
"content": "\t\tisOwner ? `delete:note:any,own` : `delete:note:any`,"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\t\tisOwner ? `delete:note:own` : `delete:note:any`,"
},
{
"type": "UnchangedLine",
"lineBefore": 93,
"lineAfter": 94,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 94,
"lineAfter": 95,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 95,
"lineAfter": 96,
"content": "\tawait prisma.note.delete({ where: { id: note.id } })"
},
{
"type": "UnchangedLine",
"lineBefore": 96,
"lineAfter": 97,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 97,
"content": "\treturn redirect(`/users/${note.owner.username}/notes`)"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\treturn redirectWithToast(`/users/${note.owner.username}/notes`, {"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "\t\ttype: 'success',"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": "\t\ttitle: 'Success',"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": "\t\tdescription: 'Your note has been deleted.',"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 98,
"lineAfter": 103,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 99,
"lineAfter": 104,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 100,
"lineAfter": 105,
"content": "export default function NoteRoute() {"
}
]
},
{
"context": "export default function NoteRoute() {",
"type": "Chunk",
"toFileRange": {
"start": 108,
"lines": 7
},
"fromFileRange": {
"start": 103,
"lines": 7
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 103,
"lineAfter": 108,
"content": "\tconst isOwner = user?.id === data.note.ownerId"
},
{
"type": "UnchangedLine",
"lineBefore": 104,
"lineAfter": 109,
"content": "\tconst canDelete = userHasPermission("
},
{
"type": "UnchangedLine",
"lineBefore": 105,
"lineAfter": 110,
"content": "\t\tuser,"
},
{
"type": "DeletedLine",
"lineBefore": 106,
"content": "\t\tisOwner ? `delete:note:any,own` : `delete:note:any`,"
},
{
"type": "AddedLine",
"lineAfter": 111,
"content": "\t\tisOwner ? `delete:note:own` : `delete:note:any`,"
},
{
"type": "UnchangedLine",
"lineBefore": 107,
"lineAfter": 112,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 108,
"lineAfter": 113,
"content": "\tconst displayBar = canDelete || isOwner"
},
{
"type": "UnchangedLine",
"lineBefore": 109,
"lineAfter": 114,
"content": ""
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/routes/users+/$username_+/notes.$noteId.tsx"
},
{
"type": "ChangedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 66
},
"fromFileRange": {
"start": 1,
"lines": 27
},
"changes": [
{
"type": "DeletedLine",
"lineBefore": 1,
"content": "import { type Password, type User } from '@prisma/client'"
},
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { type GitHubConnection, type Password, type User } from '@prisma/client'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { redirect } from '@remix-run/node'"
},
{
"type": "UnchangedLine",
"lineBefore": 2,
"lineAfter": 3,
"content": "import bcrypt from 'bcryptjs'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "import { Authenticator } from 'remix-auth'"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "import { GitHubStrategy } from 'remix-auth-github'"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "import { safeRedirect } from 'remix-utils'"
},
{
"type": "UnchangedLine",
"lineBefore": 3,
"lineAfter": 7,
"content": "import { prisma } from '~/utils/db.server.ts'"
},
{
"type": "DeletedLine",
"lineBefore": 4,
"content": "import { commitSession, getSession } from './session.server.ts'"
},
{
"type": "DeletedLine",
"lineBefore": 5,
"content": "import { redirect } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "import { combineHeaders, downloadFile } from './misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "import { sessionStorage } from './session.server.ts'"
},
{
"type": "UnchangedLine",
"lineBefore": 6,
"lineAfter": 10,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 7,
"lineAfter": 11,
"content": "export const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30"
},
{
"type": "UnchangedLine",
"lineBefore": 8,
"lineAfter": 12,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 9,
"lineAfter": 13,
"content": "export const sessionKey = 'sessionId'"
},
{
"type": "UnchangedLine",
"lineBefore": 10,
"lineAfter": 14,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "export const authenticator = new Authenticator<{"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "\tid: string"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "\temail: string"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "\tusername: string"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "\tname: string"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "\timageUrl: string"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "}>(sessionStorage)"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "authenticator.use("
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "\tnew GitHubStrategy("
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "\t\t{"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\t\t\tclientID: process.env.GITHUB_CLIENT_ID,"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "\t\t\tclientSecret: process.env.GITHUB_CLIENT_SECRET,"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\t\t\tcallbackURL: '/auth/github/callback',"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\t\tasync ({ profile }) => {"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\t\t\tconst email = profile.emails[0].value.trim().toLowerCase()"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "\t\t\tconst rawUsername = profile.displayName"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "\t\t\tconst regex = /[^a-zA-Z0-9_]/g"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\t\t\tconst username = rawUsername.replace(regex, '_').toLowerCase()"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\t\t\tconst imageUrl = profile.photos[0].value"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\t\t\treturn {"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t\t\t\temail,"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t\t\t\tid: profile.id,"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t\t\t\tusername,"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t\t\t\tname: profile.name.givenName,"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t\t\t\timageUrl,"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\t\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t),"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\tGitHubStrategy.name,"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": ")"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 11,
"lineAfter": 48,
"content": "export async function getUserId(request: Request) {"
},
{
"type": "DeletedLine",
"lineBefore": 12,
"content": "\tconst cookieSession = await getSession(request.headers.get('cookie'))"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\tconst cookieSession = await sessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 13,
"lineAfter": 52,
"content": "\tconst sessionId = cookieSession.get(sessionKey)"
},
{
"type": "UnchangedLine",
"lineBefore": 14,
"lineAfter": 53,
"content": "\tif (!sessionId) return null"
},
{
"type": "UnchangedLine",
"lineBefore": 15,
"lineAfter": 54,
"content": "\tconst session = await prisma.session.findUnique({"
},
{
"type": "UnchangedLine",
"lineBefore": 16,
"lineAfter": 55,
"content": "\t\tselect: { user: { select: { id: true } } },"
},
{
"type": "DeletedLine",
"lineBefore": 17,
"content": "\t\twhere: { id: sessionId },"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t\twhere: { id: sessionId, expirationDate: { gt: new Date() } },"
},
{
"type": "UnchangedLine",
"lineBefore": 18,
"lineAfter": 57,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 19,
"lineAfter": 58,
"content": "\tif (!session?.user) {"
},
{
"type": "UnchangedLine",
"lineBefore": 20,
"lineAfter": 59,
"content": "\t\t// Perhaps user was deleted?"
},
{
"type": "UnchangedLine",
"lineBefore": 21,
"lineAfter": 60,
"content": "\t\tcookieSession.unset(sessionKey)"
},
{
"type": "UnchangedLine",
"lineBefore": 22,
"lineAfter": 61,
"content": "\t\tthrow redirect('/', {"
},
{
"type": "UnchangedLine",
"lineBefore": 23,
"lineAfter": 62,
"content": "\t\t\theaders: {"
},
{
"type": "DeletedLine",
"lineBefore": 24,
"content": "\t\t\t\t'set-cookie': await commitSession(cookieSession),"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t\t\t\t'set-cookie': await sessionStorage.commitSession(cookieSession),"
},
{
"type": "UnchangedLine",
"lineBefore": 25,
"lineAfter": 64,
"content": "\t\t\t},"
},
{
"type": "UnchangedLine",
"lineBefore": 26,
"lineAfter": 65,
"content": "\t\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 27,
"lineAfter": 66,
"content": "\t}"
}
]
},
{
"context": "export async function login({",
"type": "Chunk",
"toFileRange": {
"start": 104,
"lines": 7
},
"fromFileRange": {
"start": 65,
"lines": 7
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 65,
"lineAfter": 104,
"content": "\tconst user = await verifyUserPassword({ username }, password)"
},
{
"type": "UnchangedLine",
"lineBefore": 66,
"lineAfter": 105,
"content": "\tif (!user) return null"
},
{
"type": "UnchangedLine",
"lineBefore": 67,
"lineAfter": 106,
"content": "\tconst session = await prisma.session.create({"
},
{
"type": "DeletedLine",
"lineBefore": 68,
"content": "\t\tselect: { id: true, expirationDate: true },"
},
{
"type": "AddedLine",
"lineAfter": 107,
"content": "\t\tselect: { id: true, expirationDate: true, userId: true },"
},
{
"type": "UnchangedLine",
"lineBefore": 69,
"lineAfter": 108,
"content": "\t\tdata: {"
},
{
"type": "UnchangedLine",
"lineBefore": 70,
"lineAfter": 109,
"content": "\t\t\texpirationDate: new Date(Date.now() + SESSION_EXPIRATION_TIME),"
},
{
"type": "UnchangedLine",
"lineBefore": 71,
"lineAfter": 110,
"content": "\t\t\tuserId: user.id,"
}
]
},
{
"context": "export async function login({",
"type": "Chunk",
"toFileRange": {
"start": 113,
"lines": 26
},
"fromFileRange": {
"start": 74,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 74,
"lineAfter": 113,
"content": "\treturn session"
},
{
"type": "UnchangedLine",
"lineBefore": 75,
"lineAfter": 114,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 76,
"lineAfter": 115,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 116,
"content": "export async function resetUserPassword({"
},
{
"type": "AddedLine",
"lineAfter": 117,
"content": "\tusername,"
},
{
"type": "AddedLine",
"lineAfter": 118,
"content": "\tpassword,"
},
{
"type": "AddedLine",
"lineAfter": 119,
"content": "}: {"
},
{
"type": "AddedLine",
"lineAfter": 120,
"content": "\tusername: User['username']"
},
{
"type": "AddedLine",
"lineAfter": 121,
"content": "\tpassword: string"
},
{
"type": "AddedLine",
"lineAfter": 122,
"content": "}) {"
},
{
"type": "AddedLine",
"lineAfter": 123,
"content": "\tconst hashedPassword = await bcrypt.hash(password, 10)"
},
{
"type": "AddedLine",
"lineAfter": 124,
"content": "\treturn prisma.user.update({"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": "\t\twhere: { username },"
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "\t\tdata: {"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": "\t\t\tpassword: {"
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": "\t\t\t\tupdate: {"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": "\t\t\t\t\thash: hashedPassword,"
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": "\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 77,
"lineAfter": 136,
"content": "export async function signup({"
},
{
"type": "UnchangedLine",
"lineBefore": 78,
"lineAfter": 137,
"content": "\temail,"
},
{
"type": "UnchangedLine",
"lineBefore": 79,
"lineAfter": 138,
"content": "\tusername,"
}
]
},
{
"context": "export async function signup({",
"type": "Chunk",
"toFileRange": {
"start": 154,
"lines": 7
},
"fromFileRange": {
"start": 95,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 95,
"lineAfter": 154,
"content": "\t\t\t\t\temail: email.toLowerCase(),"
},
{
"type": "UnchangedLine",
"lineBefore": 96,
"lineAfter": 155,
"content": "\t\t\t\t\tusername: username.toLowerCase(),"
},
{
"type": "UnchangedLine",
"lineBefore": 97,
"lineAfter": 156,
"content": "\t\t\t\t\tname,"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": "\t\t\t\t\troles: { connect: { name: 'user' } },"
},
{
"type": "UnchangedLine",
"lineBefore": 98,
"lineAfter": 158,
"content": "\t\t\t\t\tpassword: {"
},
{
"type": "UnchangedLine",
"lineBefore": 99,
"lineAfter": 159,
"content": "\t\t\t\t\t\tcreate: {"
},
{
"type": "UnchangedLine",
"lineBefore": 100,
"lineAfter": 160,
"content": "\t\t\t\t\t\t\thash: hashedPassword,"
}
]
},
{
"context": "export async function signup({",
"type": "Chunk",
"toFileRange": {
"start": 169,
"lines": 62
},
"fromFileRange": {
"start": 109,
"lines": 11
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 109,
"lineAfter": 169,
"content": "\treturn session"
},
{
"type": "UnchangedLine",
"lineBefore": 110,
"lineAfter": 170,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 111,
"lineAfter": 171,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 112,
"content": "export async function logout(request: Request) {"
},
{
"type": "DeletedLine",
"lineBefore": 113,
"content": "\tconst cookieSession = await getSession(request.headers.get('cookie'))"
},
{
"type": "AddedLine",
"lineAfter": 172,
"content": "export async function signupWithGitHub({"
},
{
"type": "AddedLine",
"lineAfter": 173,
"content": "\temail,"
},
{
"type": "AddedLine",
"lineAfter": 174,
"content": "\tusername,"
},
{
"type": "AddedLine",
"lineAfter": 175,
"content": "\tname,"
},
{
"type": "AddedLine",
"lineAfter": 176,
"content": "\tgitHubId,"
},
{
"type": "AddedLine",
"lineAfter": 177,
"content": "\timageUrl,"
},
{
"type": "AddedLine",
"lineAfter": 178,
"content": "}: {"
},
{
"type": "AddedLine",
"lineAfter": 179,
"content": "\temail: User['email']"
},
{
"type": "AddedLine",
"lineAfter": 180,
"content": "\tusername: User['username']"
},
{
"type": "AddedLine",
"lineAfter": 181,
"content": "\tname: User['name']"
},
{
"type": "AddedLine",
"lineAfter": 182,
"content": "\tgitHubId: GitHubConnection['providerId']"
},
{
"type": "AddedLine",
"lineAfter": 183,
"content": "\timageUrl?: string"
},
{
"type": "AddedLine",
"lineAfter": 184,
"content": "}) {"
},
{
"type": "AddedLine",
"lineAfter": 185,
"content": "\tconst session = await prisma.session.create({"
},
{
"type": "AddedLine",
"lineAfter": 186,
"content": "\t\tdata: {"
},
{
"type": "AddedLine",
"lineAfter": 187,
"content": "\t\t\texpirationDate: new Date(Date.now() + SESSION_EXPIRATION_TIME),"
},
{
"type": "AddedLine",
"lineAfter": 188,
"content": "\t\t\tuser: {"
},
{
"type": "AddedLine",
"lineAfter": 189,
"content": "\t\t\t\tcreate: {"
},
{
"type": "AddedLine",
"lineAfter": 190,
"content": "\t\t\t\t\temail: email.toLowerCase(),"
},
{
"type": "AddedLine",
"lineAfter": 191,
"content": "\t\t\t\t\tusername: username.toLowerCase(),"
},
{
"type": "AddedLine",
"lineAfter": 192,
"content": "\t\t\t\t\tname,"
},
{
"type": "AddedLine",
"lineAfter": 193,
"content": "\t\t\t\t\tgitHubConnections: { create: { providerId: gitHubId } },"
},
{
"type": "AddedLine",
"lineAfter": 194,
"content": "\t\t\t\t\timage: imageUrl"
},
{
"type": "AddedLine",
"lineAfter": 195,
"content": "\t\t\t\t\t\t? { create: await downloadFile(imageUrl) }"
},
{
"type": "AddedLine",
"lineAfter": 196,
"content": "\t\t\t\t\t\t: undefined,"
},
{
"type": "AddedLine",
"lineAfter": 197,
"content": "\t\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 198,
"content": "\t\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 199,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 200,
"content": "\t\tselect: { id: true, expirationDate: true },"
},
{
"type": "AddedLine",
"lineAfter": 201,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 202,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 203,
"content": "\treturn session"
},
{
"type": "AddedLine",
"lineAfter": 204,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 205,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 206,
"content": "export async function logout("
},
{
"type": "AddedLine",
"lineAfter": 207,
"content": "\t{"
},
{
"type": "AddedLine",
"lineAfter": 208,
"content": "\t\trequest,"
},
{
"type": "AddedLine",
"lineAfter": 209,
"content": "\t\tredirectTo = '/',"
},
{
"type": "AddedLine",
"lineAfter": 210,
"content": "\t}: {"
},
{
"type": "AddedLine",
"lineAfter": 211,
"content": "\t\trequest: Request"
},
{
"type": "AddedLine",
"lineAfter": 212,
"content": "\t\tredirectTo?: string"
},
{
"type": "AddedLine",
"lineAfter": 213,
"content": "\t},"
},
{
"type": "AddedLine",
"lineAfter": 214,
"content": "\tresponseInit?: ResponseInit,"
},
{
"type": "AddedLine",
"lineAfter": 215,
"content": ") {"
},
{
"type": "AddedLine",
"lineAfter": 216,
"content": "\tconst cookieSession = await sessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 217,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 218,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 219,
"content": "\tconst sessionId = cookieSession.get(sessionKey)"
},
{
"type": "AddedLine",
"lineAfter": 220,
"content": "\tawait prisma.session.delete({ where: { id: sessionId } })"
},
{
"type": "UnchangedLine",
"lineBefore": 114,
"lineAfter": 221,
"content": "\tcookieSession.unset(sessionKey)"
},
{
"type": "DeletedLine",
"lineBefore": 115,
"content": "\tthrow redirect('/', {"
},
{
"type": "DeletedLine",
"lineBefore": 116,
"content": "\t\theaders: { 'set-cookie': await commitSession(cookieSession) },"
},
{
"type": "AddedLine",
"lineAfter": 222,
"content": "\tthrow redirect(safeRedirect(redirectTo), {"
},
{
"type": "AddedLine",
"lineAfter": 223,
"content": "\t\t...responseInit,"
},
{
"type": "AddedLine",
"lineAfter": 224,
"content": "\t\theaders: combineHeaders("
},
{
"type": "AddedLine",
"lineAfter": 225,
"content": "\t\t\t{ 'set-cookie': await sessionStorage.commitSession(cookieSession) },"
},
{
"type": "AddedLine",
"lineAfter": 226,
"content": "\t\t\tresponseInit?.headers,"
},
{
"type": "AddedLine",
"lineAfter": 227,
"content": "\t\t),"
},
{
"type": "UnchangedLine",
"lineBefore": 117,
"lineAfter": 228,
"content": "\t})"
},
{
"type": "UnchangedLine",
"lineBefore": 118,
"lineAfter": 229,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 119,
"lineAfter": 230,
"content": ""
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/auth.server.ts"
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "const prisma = singleton('prisma', () => {",
"type": "Chunk",
"toFileRange": {
"start": 11,
"lines": 7
},
"fromFileRange": {
"start": 11,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 11,
"lineAfter": 11,
"content": "\t\tlog: ["
},
{
"type": "UnchangedLine",
"lineBefore": 12,
"lineAfter": 12,
"content": "\t\t\t{ level: 'query', emit: 'event' },"
},
{
"type": "UnchangedLine",
"lineBefore": 13,
"lineAfter": 13,
"content": "\t\t\t{ level: 'error', emit: 'stdout' },"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "\t\t\t{ level: 'info', emit: 'stdout' },"
},
{
"type": "UnchangedLine",
"lineBefore": 14,
"lineAfter": 15,
"content": "\t\t\t{ level: 'warn', emit: 'stdout' },"
},
{
"type": "UnchangedLine",
"lineBefore": 15,
"lineAfter": 16,
"content": "\t\t],"
},
{
"type": "UnchangedLine",
"lineBefore": 16,
"lineAfter": 17,
"content": "\t})"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/db.server.ts"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 85
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { type ReactElement } from 'react'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { renderAsync } from '@react-email/components'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "const ResendErrorSchema = z.union(["
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\tz.object({"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\t\tname: z.string(),"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "\t\tmessage: z.string(),"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "\t\tstatusCode: z.number(),"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "\t}),"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "\tz.object({"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\t\tname: z.literal('UnknownError'),"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\t\tmessage: z.literal('Unknown Error'),"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "\t\tstatusCode: z.literal(500),"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "\t\tcause: z.any(),"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "\t}),"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "])"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "type ResendError = z.infer<typeof ResendErrorSchema>"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "const ResendSuccessSchema = z.object({"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": "\tid: z.string(),"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "})"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "export async function sendEmail({"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "\treact,"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\t...options"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "}: {"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\tto: string"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\tsubject: string"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "} & ("
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "\t| { html: string; text: string; react?: never }"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": "\t| { react: ReactElement; html?: never; text?: never }"
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": ")) {"
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\tconst from = 'hello@epicstack.dev'"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\tconst email = {"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": "\t\tfrom,"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\t\t...options,"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t\t...(react ? await renderReactEmail(react) : null),"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "\tconst response = await fetch('https://api.resend.com/emails', {"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": "\t\tmethod: 'POST',"
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "\t\tbody: JSON.stringify(email),"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\t\theaders: {"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\t\t\tAuthorization: `Bearer ${process.env.RESEND_API_KEY}`,"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\t\t\t'Content-Type': 'application/json',"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\t\t},"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\tconst data = await response.json()"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "\tconst parsedData = ResendSuccessSchema.safeParse(data)"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\tif (response.ok && parsedData.success) {"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\t\treturn {"
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\t\t\tstatus: 'success',"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t\t\tdata: parsedData,"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\t\t} as const"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\t} else {"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\t\tconst parseResult = ResendErrorSchema.safeParse(data)"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t\tif (parseResult.success) {"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t\t\treturn {"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\t\t\t\tstatus: 'error',"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "\t\t\t\terror: parseResult.data,"
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": "\t\t\t} as const"
},
{
"type": "AddedLine",
"lineAfter": 65,
"content": "\t\t} else {"
},
{
"type": "AddedLine",
"lineAfter": 66,
"content": "\t\t\treturn {"
},
{
"type": "AddedLine",
"lineAfter": 67,
"content": "\t\t\t\tstatus: 'error',"
},
{
"type": "AddedLine",
"lineAfter": 68,
"content": "\t\t\t\terror: {"
},
{
"type": "AddedLine",
"lineAfter": 69,
"content": "\t\t\t\t\tname: 'UnknownError',"
},
{
"type": "AddedLine",
"lineAfter": 70,
"content": "\t\t\t\t\tmessage: 'Unknown Error',"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\t\t\t\t\tstatusCode: 500,"
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t\t\t\t\tcause: data,"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\t\t\t\t} satisfies ResendError,"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\t\t\t} as const"
},
{
"type": "AddedLine",
"lineAfter": 75,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 76,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 77,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 79,
"content": "async function renderReactEmail(react: ReactElement) {"
},
{
"type": "AddedLine",
"lineAfter": 80,
"content": "\tconst [html, text] = await Promise.all(["
},
{
"type": "AddedLine",
"lineAfter": 81,
"content": "\t\trenderAsync(react),"
},
{
"type": "AddedLine",
"lineAfter": 82,
"content": "\t\trenderAsync(react, { plainText: true }),"
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "\t])"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "\treturn { html, text }"
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/email.server.ts"
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "const schema = z.object({",
"type": "Chunk",
"toFileRange": {
"start": 4,
"lines": 10
},
"fromFileRange": {
"start": 4,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 4,
"lineAfter": 4,
"content": "\tNODE_ENV: z.enum(['production', 'development', 'test'] as const),"
},
{
"type": "UnchangedLine",
"lineBefore": 5,
"lineAfter": 5,
"content": "\tDATABASE_URL: z.string(),"
},
{
"type": "UnchangedLine",
"lineBefore": 6,
"lineAfter": 6,
"content": "\tSESSION_SECRET: z.string(),"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\tRESEND_API_KEY: z.string(),"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "\tGITHUB_TOKEN: z.string(),"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "\tGITHUB_CLIENT_ID: z.string(),"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "\tGITHUB_CLIENT_SECRET: z.string(),"
},
{
"type": "UnchangedLine",
"lineBefore": 7,
"lineAfter": 11,
"content": "})"
},
{
"type": "UnchangedLine",
"lineBefore": 8,
"lineAfter": 12,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 9,
"lineAfter": 13,
"content": "declare global {"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/env.server.ts"
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "export function combineHeaders(...headers: Array<ResponseInit['headers']>) {",
"type": "Chunk",
"toFileRange": {
"start": 82,
"lines": 22
},
"fromFileRange": {
"start": 82,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 82,
"lineAfter": 82,
"content": "\treturn combined"
},
{
"type": "UnchangedLine",
"lineBefore": 83,
"lineAfter": 83,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 84,
"lineAfter": 84,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": "/**"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": " * Combine multiple response init objects into one (uses combineHeaders)"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": " */"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "export function combineResponseInits("
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "\t...responseInits: Array<ResponseInit | undefined>"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": ") {"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": "\tlet combined: ResponseInit = {}"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": "\tfor (const responseInit of responseInits) {"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": "\t\tcombined = {"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": "\t\t\t...responseInit,"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": "\t\t\theaders: combineHeaders(combined.headers, responseInit?.headers),"
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": "\treturn combined"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 85,
"lineAfter": 101,
"content": "/**"
},
{
"type": "UnchangedLine",
"lineBefore": 86,
"lineAfter": 102,
"content": " * Provide a condition and if that condition is falsey, this throws an error"
},
{
"type": "UnchangedLine",
"lineBefore": 87,
"lineAfter": 103,
"content": " * with the given message."
}
]
},
{
"context": "export function invariantResponse(",
"type": "Chunk",
"toFileRange": {
"start": 155,
"lines": 7
},
"fromFileRange": {
"start": 139,
"lines": 7
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 139,
"lineAfter": 155,
"content": " * Returns true if the current navigation is submitting the current route's"
},
{
"type": "UnchangedLine",
"lineBefore": 140,
"lineAfter": 156,
"content": " * form. Defaults to the current route's form action and method POST."
},
{
"type": "UnchangedLine",
"lineBefore": 141,
"lineAfter": 157,
"content": " *"
},
{
"type": "DeletedLine",
"lineBefore": 142,
"content": " * If GET, then this uses navigation.state === 'loading' instead of submitting."
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": " * Defaults state to 'non-idle'"
},
{
"type": "UnchangedLine",
"lineBefore": 143,
"lineAfter": 159,
"content": " *"
},
{
"type": "UnchangedLine",
"lineBefore": 144,
"lineAfter": 160,
"content": " * NOTE: the default formAction will include query params, but the"
},
{
"type": "UnchangedLine",
"lineBefore": 145,
"lineAfter": 161,
"content": " * navigation.formAction will not, so don't use the default formAction if you"
}
]
},
{
"context": "export function invariantResponse(",
"type": "Chunk",
"toFileRange": {
"start": 164,
"lines": 20
},
"fromFileRange": {
"start": 148,
"lines": 14
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 148,
"lineAfter": 164,
"content": "export function useIsPending({"
},
{
"type": "UnchangedLine",
"lineBefore": 149,
"lineAfter": 165,
"content": "\tformAction,"
},
{
"type": "UnchangedLine",
"lineBefore": 150,
"lineAfter": 166,
"content": "\tformMethod = 'POST',"
},
{
"type": "AddedLine",
"lineAfter": 167,
"content": "\tstate = 'non-idle',"
},
{
"type": "UnchangedLine",
"lineBefore": 151,
"lineAfter": 168,
"content": "}: {"
},
{
"type": "UnchangedLine",
"lineBefore": 152,
"lineAfter": 169,
"content": "\tformAction?: string"
},
{
"type": "UnchangedLine",
"lineBefore": 153,
"lineAfter": 170,
"content": "\tformMethod?: 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE'"
},
{
"type": "AddedLine",
"lineAfter": 171,
"content": "\tstate?: 'submitting' | 'loading' | 'non-idle'"
},
{
"type": "UnchangedLine",
"lineBefore": 154,
"lineAfter": 172,
"content": "} = {}) {"
},
{
"type": "UnchangedLine",
"lineBefore": 155,
"lineAfter": 173,
"content": "\tconst contextualFormAction = useFormAction()"
},
{
"type": "UnchangedLine",
"lineBefore": 156,
"lineAfter": 174,
"content": "\tconst navigation = useNavigation()"
},
{
"type": "AddedLine",
"lineAfter": 175,
"content": "\tconst isPendingState ="
},
{
"type": "AddedLine",
"lineAfter": 176,
"content": "\t\tstate === 'non-idle'"
},
{
"type": "AddedLine",
"lineAfter": 177,
"content": "\t\t\t? navigation.state !== 'idle'"
},
{
"type": "AddedLine",
"lineAfter": 178,
"content": "\t\t\t: navigation.state === state"
},
{
"type": "UnchangedLine",
"lineBefore": 157,
"lineAfter": 179,
"content": "\treturn ("
},
{
"type": "DeletedLine",
"lineBefore": 158,
"content": "\t\tnavigation.state === (formMethod === 'GET' ? 'loading' : 'submitting') &&"
},
{
"type": "AddedLine",
"lineAfter": 180,
"content": "\t\tisPendingState &&"
},
{
"type": "UnchangedLine",
"lineBefore": 159,
"lineAfter": 181,
"content": "\t\tnavigation.formAction === (formAction ?? contextualFormAction) &&"
},
{
"type": "UnchangedLine",
"lineBefore": 160,
"lineAfter": 182,
"content": "\t\tnavigation.formMethod === formMethod"
},
{
"type": "UnchangedLine",
"lineBefore": 161,
"lineAfter": 183,
"content": "\t)"
}
]
},
{
"context": "export function useDebounce<",
"type": "Chunk",
"toFileRange": {
"start": 288,
"lines": 19
},
"fromFileRange": {
"start": 266,
"lines": 3
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 266,
"lineAfter": 288,
"content": "\t\t[delay],"
},
{
"type": "UnchangedLine",
"lineBefore": 267,
"lineAfter": 289,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 268,
"lineAfter": 290,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 291,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 292,
"content": "export async function downloadFile(url: string, retries: number = 0) {"
},
{
"type": "AddedLine",
"lineAfter": 293,
"content": "\tconst MAX_RETRIES = 3"
},
{
"type": "AddedLine",
"lineAfter": 294,
"content": "\ttry {"
},
{
"type": "AddedLine",
"lineAfter": 295,
"content": "\t\tconst response = await fetch(url)"
},
{
"type": "AddedLine",
"lineAfter": 296,
"content": "\t\tif (!response.ok) {"
},
{
"type": "AddedLine",
"lineAfter": 297,
"content": "\t\t\tthrow new Error(`Failed to fetch image with status ${response.status}`)"
},
{
"type": "AddedLine",
"lineAfter": 298,
"content": "\t\t}"
},
{
"type": "AddedLine",
"lineAfter": 299,
"content": "\t\tconst contentType = response.headers.get('content-type') ?? 'image/jpg'"
},
{
"type": "AddedLine",
"lineAfter": 300,
"content": "\t\tconst blob = Buffer.from(await response.arrayBuffer())"
},
{
"type": "AddedLine",
"lineAfter": 301,
"content": "\t\treturn { contentType, blob }"
},
{
"type": "AddedLine",
"lineAfter": 302,
"content": "\t} catch (e) {"
},
{
"type": "AddedLine",
"lineAfter": 303,
"content": "\t\tif (retries > MAX_RETRIES) throw e"
},
{
"type": "AddedLine",
"lineAfter": 304,
"content": "\t\treturn downloadFile(url, retries + 1)"
},
{
"type": "AddedLine",
"lineAfter": 305,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 306,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/misc.tsx"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 17
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import * as cookie from 'cookie'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "const key = 'redirectTo'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "export const destroyRedirectToHeader = cookie.serialize(key, '', { maxAge: -1 })"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "export function getRedirectCookieHeader(redirectTo?: string) {"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\treturn redirectTo && redirectTo !== '/'"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "\t\t? cookie.serialize(key, redirectTo, { maxAge: 60 * 10 })"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "\t\t: null"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "export function getRedirectCookieValue(request: Request) {"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\tconst rawCookie = request.headers.get('cookie')"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "\tconst parsedCookies = rawCookie ? cookie.parse(rawCookie) : {}"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "\tconst redirectTo = parsedCookies[key]"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "\treturn redirectTo || null"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/redirect-cookie.server.ts"
},
{
"type": "ChangedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 12
},
"fromFileRange": {
"start": 1,
"lines": 14
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 1,
"lineAfter": 1,
"content": "import { createCookieSessionStorage } from '@remix-run/node'"
},
{
"type": "UnchangedLine",
"lineBefore": 2,
"lineAfter": 2,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 3,
"content": "const sessionStorage = createCookieSessionStorage({"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "export const sessionStorage = createCookieSessionStorage({"
},
{
"type": "UnchangedLine",
"lineBefore": 4,
"lineAfter": 4,
"content": "\tcookie: {"
},
{
"type": "DeletedLine",
"lineBefore": 5,
"content": "\t\tname: '_session',"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "\t\tname: 'en_session',"
},
{
"type": "UnchangedLine",
"lineBefore": 6,
"lineAfter": 6,
"content": "\t\tsameSite: 'lax',"
},
{
"type": "UnchangedLine",
"lineBefore": 7,
"lineAfter": 7,
"content": "\t\tpath: '/',"
},
{
"type": "UnchangedLine",
"lineBefore": 8,
"lineAfter": 8,
"content": "\t\thttpOnly: true,"
},
{
"type": "DeletedLine",
"lineBefore": 9,
"content": "\t\tsecrets: [process.env.SESSION_SECRET],"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "\t\tsecrets: process.env.SESSION_SECRET.split(','),"
},
{
"type": "UnchangedLine",
"lineBefore": 10,
"lineAfter": 10,
"content": "\t\tsecure: process.env.NODE_ENV === 'production',"
},
{
"type": "UnchangedLine",
"lineBefore": 11,
"lineAfter": 11,
"content": "\t},"
},
{
"type": "UnchangedLine",
"lineBefore": 12,
"lineAfter": 12,
"content": "})"
},
{
"type": "DeletedLine",
"lineBefore": 13,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 14,
"content": "export const { getSession, commitSession, destroySession } = sessionStorage"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/session.server.ts"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 63
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { createCookieSessionStorage, redirect } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import { createId as cuid } from '@paralleldrive/cuid2'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import { z } from 'zod'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "import { combineHeaders } from './misc.tsx'"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "const toastKey = 'toast'"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "const TypeSchema = z.enum(['message', 'success', 'error'])"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "const ToastSchema = z.object({"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "\tdescription: z.string(),"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "\tid: z.string().default(() => cuid()),"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\ttitle: z.string().optional(),"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "\ttype: TypeSchema.default('message'),"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "})"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": "export type Toast = z.infer<typeof ToastSchema>"
},
{
"type": "AddedLine",
"lineAfter": 17,
"content": "export type OptionalToast = Omit<Toast, 'id' | 'type'> & {"
},
{
"type": "AddedLine",
"lineAfter": 18,
"content": "\tid?: string"
},
{
"type": "AddedLine",
"lineAfter": 19,
"content": "\ttype?: z.infer<typeof TypeSchema>"
},
{
"type": "AddedLine",
"lineAfter": 20,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": "const toastSessionStorage = createCookieSessionStorage({"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": "\tcookie: {"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": "\t\tname: 'en_toast',"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": "\t\tsameSite: 'lax',"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": "\t\tpath: '/',"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": "\t\thttpOnly: true,"
},
{
"type": "AddedLine",
"lineAfter": 28,
"content": "\t\tsecrets: process.env.SESSION_SECRET.split(','),"
},
{
"type": "AddedLine",
"lineAfter": 29,
"content": "\t\tsecure: process.env.NODE_ENV === 'production',"
},
{
"type": "AddedLine",
"lineAfter": 30,
"content": "\t},"
},
{
"type": "AddedLine",
"lineAfter": 31,
"content": "})"
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 33,
"content": "export async function redirectWithToast("
},
{
"type": "AddedLine",
"lineAfter": 34,
"content": "\turl: string,"
},
{
"type": "AddedLine",
"lineAfter": 35,
"content": "\ttoast: OptionalToast,"
},
{
"type": "AddedLine",
"lineAfter": 36,
"content": "\tinit?: ResponseInit,"
},
{
"type": "AddedLine",
"lineAfter": 37,
"content": ") {"
},
{
"type": "AddedLine",
"lineAfter": 38,
"content": "\treturn redirect(url, {"
},
{
"type": "AddedLine",
"lineAfter": 39,
"content": "\t\t...init,"
},
{
"type": "AddedLine",
"lineAfter": 40,
"content": "\t\theaders: combineHeaders(init?.headers, await createToastHeaders(toast)),"
},
{
"type": "AddedLine",
"lineAfter": 41,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 42,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 43,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 44,
"content": "export async function createToastHeaders(optionalToast: OptionalToast) {"
},
{
"type": "AddedLine",
"lineAfter": 45,
"content": "\tconst session = await toastSessionStorage.getSession()"
},
{
"type": "AddedLine",
"lineAfter": 46,
"content": "\tconst toast = ToastSchema.parse(optionalToast)"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\t// using session.flash so next time .get is called, it's immediately removed"
},
{
"type": "AddedLine",
"lineAfter": 48,
"content": "\tsession.flash(toastKey, toast)"
},
{
"type": "AddedLine",
"lineAfter": 49,
"content": "\tconst cookie = await toastSessionStorage.commitSession(session)"
},
{
"type": "AddedLine",
"lineAfter": 50,
"content": "\treturn new Headers({ 'set-cookie': cookie })"
},
{
"type": "AddedLine",
"lineAfter": 51,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 52,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "export async function getToast(request: Request) {"
},
{
"type": "AddedLine",
"lineAfter": 54,
"content": "\tconst session = await toastSessionStorage.getSession("
},
{
"type": "AddedLine",
"lineAfter": 55,
"content": "\t\trequest.headers.get('cookie'),"
},
{
"type": "AddedLine",
"lineAfter": 56,
"content": "\t)"
},
{
"type": "AddedLine",
"lineAfter": 57,
"content": "\tconst result = ToastSchema.safeParse(session.get(toastKey))"
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": "\tconst toast = result.success ? result.data : null"
},
{
"type": "AddedLine",
"lineAfter": 59,
"content": "\tconst headers = new Headers({"
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": "\t\t'set-cookie': await toastSessionStorage.commitSession(session),"
},
{
"type": "AddedLine",
"lineAfter": 61,
"content": "\t})"
},
{
"type": "AddedLine",
"lineAfter": 62,
"content": "\treturn { toast, headers }"
},
{
"type": "AddedLine",
"lineAfter": 63,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/toast.server.ts"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 13
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import { createCookieSessionStorage } from '@remix-run/node'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "export const verifySessionStorage = createCookieSessionStorage({"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "\tcookie: {"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "\t\tname: 'en_verification',"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\t\tsameSite: 'lax',"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\t\tpath: '/',"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "\t\thttpOnly: true,"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "\t\tmaxAge: 60 * 10, // 10 minutes"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "\t\tsecrets: process.env.SESSION_SECRET.split(','),"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "\t\tsecure: process.env.NODE_ENV === 'production',"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": "\t},"
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "})"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/app/utils/verification.server.ts"
},
{
"type": "ChangedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 21
},
"fromFileRange": {
"start": 1,
"lines": 7
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "import 'dotenv/config'"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "import closeWithGrace from 'close-with-grace'"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "import chalk from 'chalk'"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": "closeWithGrace(async ({ err }) => {"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": "\tif (err) {"
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": "\t\tconsole.error(chalk.red(err))"
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": "\t\tconsole.error(chalk.red(err.stack))"
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": "\t\tprocess.exit(1)"
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": "\t}"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "})"
},
{
"type": "AddedLine",
"lineAfter": 12,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 13,
"content": "if (process.env.MOCKS === 'true') {"
},
{
"type": "AddedLine",
"lineAfter": 14,
"content": "\tawait import('./tests/mocks/index.ts')"
},
{
"type": "AddedLine",
"lineAfter": 15,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 16,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 1,
"lineAfter": 17,
"content": "if (process.env.NODE_ENV === 'production') {"
},
{
"type": "UnchangedLine",
"lineBefore": 2,
"lineAfter": 18,
"content": "\tawait import('./server-build/index.js')"
},
{
"type": "UnchangedLine",
"lineBefore": 3,
"lineAfter": 19,
"content": "} else {"
},
{
"type": "UnchangedLine",
"lineBefore": 4,
"lineAfter": 20,
"content": "\tawait import('./server/index.ts')"
},
{
"type": "UnchangedLine",
"lineBefore": 5,
"lineAfter": 21,
"content": "}"
},
{
"type": "DeletedLine",
"lineBefore": 6,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 7,
"content": "// wat"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/index.js"
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "async function generateIconFiles() {",
"type": "Chunk",
"toFileRange": {
"start": 44,
"lines": 13
},
"fromFileRange": {
"start": 44,
"lines": 15
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 44,
"lineAfter": 44,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 45,
"lineAfter": 45,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 46,
"lineAfter": 46,
"content": "\tif (spriteUpToDate && typesUpToDate) {"
},
{
"type": "DeletedLine",
"lineBefore": 47,
"content": "\t\tconsole.log(`Icons are up to date`)"
},
{
"type": "AddedLine",
"lineAfter": 47,
"content": "\t\tlogVerbose(`Icons are up to date`)"
},
{
"type": "UnchangedLine",
"lineBefore": 48,
"lineAfter": 48,
"content": "\t\treturn"
},
{
"type": "UnchangedLine",
"lineBefore": 49,
"lineAfter": 49,
"content": "\t}"
},
{
"type": "UnchangedLine",
"lineBefore": 50,
"lineAfter": 50,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 51,
"lineAfter": 51,
"content": "\tlogVerbose(`Generating sprite for ${inputDirRelative}`)"
},
{
"type": "UnchangedLine",
"lineBefore": 52,
"lineAfter": 52,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 53,
"content": "\tawait fsExtra.emptyDir(outputDir)"
},
{
"type": "DeletedLine",
"lineBefore": 54,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 55,
"content": "\tawait generateSvgSprite({"
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": "\tconst spriteChanged = await generateSvgSprite({"
},
{
"type": "UnchangedLine",
"lineBefore": 56,
"lineAfter": 54,
"content": "\t\tfiles,"
},
{
"type": "UnchangedLine",
"lineBefore": 57,
"lineAfter": 55,
"content": "\t\tinputDir,"
},
{
"type": "UnchangedLine",
"lineBefore": 58,
"lineAfter": 56,
"content": "\t\toutputPath: spriteFilepath,"
}
]
},
{
"context": "async function generateIconFiles() {",
"type": "Chunk",
"toFileRange": {
"start": 68,
"lines": 14
},
"fromFileRange": {
"start": 70,
"lines": 11
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 70,
"lineAfter": 68,
"content": "export type IconName ="
},
{
"type": "UnchangedLine",
"lineBefore": 71,
"lineAfter": 69,
"content": "\\t| ${stringifiedIconNames.join('\\n\\t| ')};"
},
{
"type": "UnchangedLine",
"lineBefore": 72,
"lineAfter": 70,
"content": "`"
},
{
"type": "DeletedLine",
"lineBefore": 73,
"content": "\tawait writeIfChanged(typeOutputFilepath, typeOutputContent)"
},
{
"type": "AddedLine",
"lineAfter": 71,
"content": "\tconst typesChanged = await writeIfChanged("
},
{
"type": "AddedLine",
"lineAfter": 72,
"content": "\t\ttypeOutputFilepath,"
},
{
"type": "AddedLine",
"lineAfter": 73,
"content": "\t\ttypeOutputContent,"
},
{
"type": "AddedLine",
"lineAfter": 74,
"content": "\t)"
},
{
"type": "UnchangedLine",
"lineBefore": 74,
"lineAfter": 75,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 75,
"lineAfter": 76,
"content": "\tlogVerbose(`Manifest saved to ${path.relative(cwd, typeOutputFilepath)}`)"
},
{
"type": "UnchangedLine",
"lineBefore": 76,
"lineAfter": 77,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 77,
"content": "\tawait writeIfChanged("
},
{
"type": "AddedLine",
"lineAfter": 78,
"content": "\tconst readmeChanged = await writeIfChanged("
},
{
"type": "UnchangedLine",
"lineBefore": 78,
"lineAfter": 79,
"content": "\t\tpath.join(outputDir, 'README.md'),"
},
{
"type": "UnchangedLine",
"lineBefore": 79,
"lineAfter": 80,
"content": "\t\t`# Icons"
},
{
"type": "UnchangedLine",
"lineBefore": 80,
"lineAfter": 81,
"content": ""
}
]
},
{
"context": "This directory contains SVG icons that are used by the app.",
"type": "Chunk",
"toFileRange": {
"start": 84,
"lines": 10
},
"fromFileRange": {
"start": 83,
"lines": 7
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 83,
"lineAfter": 84,
"content": "Everything in this directory is generated by \\`npm run build:icons\\`."
},
{
"type": "UnchangedLine",
"lineBefore": 84,
"lineAfter": 85,
"content": "`,"
},
{
"type": "UnchangedLine",
"lineBefore": 85,
"lineAfter": 86,
"content": "\t)"
},
{
"type": "DeletedLine",
"lineBefore": 86,
"content": "\tconsole.log(`Generated ${files.length} icons`)"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": "\tif (spriteChanged || typesChanged || readmeChanged) {"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": "\t\tconsole.log(`Generated ${files.length} icons`)"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": "\t}"
},
{
"type": "UnchangedLine",
"lineBefore": 87,
"lineAfter": 91,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 88,
"lineAfter": 92,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 89,
"lineAfter": 93,
"content": "function iconName(file: string) {"
}
]
},
{
"context": "async function writeIfChanged(filepath: string, newContent: string) {",
"type": "Chunk",
"toFileRange": {
"start": 145,
"lines": 7
},
"fromFileRange": {
"start": 141,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 141,
"lineAfter": 145,
"content": "\tconst currentContent = await fsExtra"
},
{
"type": "UnchangedLine",
"lineBefore": 142,
"lineAfter": 146,
"content": "\t\t.readFile(filepath, 'utf8')"
},
{
"type": "UnchangedLine",
"lineBefore": 143,
"lineAfter": 147,
"content": "\t\t.catch(() => '')"
},
{
"type": "DeletedLine",
"lineBefore": 144,
"content": "\tif (currentContent === newContent) return"
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": "\tif (currentContent === newContent) return false"
},
{
"type": "UnchangedLine",
"lineBefore": 145,
"lineAfter": 149,
"content": "\tawait fsExtra.writeFile(filepath, newContent, 'utf8')"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": "\treturn true"
},
{
"type": "UnchangedLine",
"lineBefore": 146,
"lineAfter": 151,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/build-icons.ts"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 11
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "<!-- Downloaded from @radix-ui/icons -->"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "<!-- License https://github.com/radix-ui/icons/blob/master/LICENSE -->"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "<!-- https://github.com/radix-ui/icons/blob/master/packages/radix-icons/icons/github-log.svg -->"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "<svg width=\"15\" height=\"15\" viewBox=\"0 0 15 15\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": " <path"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": " fill-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": " clip-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": " d=\"M7.49933 0.25C3.49635 0.25 0.25 3.49593 0.25 7.50024C0.25 10.703 2.32715 13.4206 5.2081 14.3797C5.57084 14.446 5.70302 14.2222 5.70302 14.0299C5.70302 13.8576 5.69679 13.4019 5.69323 12.797C3.67661 13.235 3.25112 11.825 3.25112 11.825C2.92132 10.9874 2.44599 10.7644 2.44599 10.7644C1.78773 10.3149 2.49584 10.3238 2.49584 10.3238C3.22353 10.375 3.60629 11.0711 3.60629 11.0711C4.25298 12.1788 5.30335 11.8588 5.71638 11.6732C5.78225 11.205 5.96962 10.8854 6.17658 10.7043C4.56675 10.5209 2.87415 9.89918 2.87415 7.12104C2.87415 6.32925 3.15677 5.68257 3.62053 5.17563C3.54576 4.99226 3.29697 4.25521 3.69174 3.25691C3.69174 3.25691 4.30015 3.06196 5.68522 3.99973C6.26337 3.83906 6.8838 3.75895 7.50022 3.75583C8.1162 3.75895 8.73619 3.83906 9.31523 3.99973C10.6994 3.06196 11.3069 3.25691 11.3069 3.25691C11.7026 4.25521 11.4538 4.99226 11.3795 5.17563C11.8441 5.68257 12.1245 6.32925 12.1245 7.12104C12.1245 9.9063 10.4292 10.5192 8.81452 10.6985C9.07444 10.9224 9.30633 11.3648 9.30633 12.0413C9.30633 13.0102 9.29742 13.7922 9.29742 14.0299C9.29742 14.2239 9.42828 14.4496 9.79591 14.3788C12.6746 13.4179 14.75 10.7025 14.75 7.50024C14.75 3.49593 11.5036 0.25 7.49933 0.25Z\""
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": " fill=\"currentColor\""
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": " />"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "</svg>"
},
{
"type": "MessageLine",
"content": "No newline at end of file"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/github-logo.svg"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 11
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "<!-- Downloaded from @radix-ui/icons -->"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "<!-- License https://github.com/radix-ui/icons/blob/master/LICENSE -->"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "<!-- https://github.com/radix-ui/icons/blob/master/packages/radix-icons/icons/link-2.svg -->"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "<svg width=\"15\" height=\"15\" viewBox=\"0 0 15 15\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": " <path"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": " fill-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": " clip-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": " d=\"M8.51194 3.00541C9.18829 2.54594 10.0435 2.53694 10.6788 2.95419C10.8231 3.04893 10.9771 3.1993 11.389 3.61119C11.8009 4.02307 11.9513 4.17714 12.046 4.32141C12.4633 4.95675 12.4543 5.81192 11.9948 6.48827C11.8899 6.64264 11.7276 6.80811 11.3006 7.23511L10.6819 7.85383C10.4867 8.04909 10.4867 8.36567 10.6819 8.56093C10.8772 8.7562 11.1938 8.7562 11.389 8.56093L12.0077 7.94221L12.0507 7.89929C12.4203 7.52976 12.6568 7.2933 12.822 7.0502C13.4972 6.05623 13.5321 4.76252 12.8819 3.77248C12.7233 3.53102 12.4922 3.30001 12.1408 2.94871L12.0961 2.90408L12.0515 2.85942C11.7002 2.508 11.4692 2.27689 11.2277 2.11832C10.2377 1.46813 8.94398 1.50299 7.95001 2.17822C7.70691 2.34336 7.47044 2.57991 7.1009 2.94955L7.058 2.99247L6.43928 3.61119C6.24401 3.80645 6.24401 4.12303 6.43928 4.31829C6.63454 4.51355 6.95112 4.51355 7.14638 4.31829L7.7651 3.69957C8.1921 3.27257 8.35757 3.11027 8.51194 3.00541ZM4.31796 7.14672C4.51322 6.95146 4.51322 6.63487 4.31796 6.43961C4.12269 6.24435 3.80611 6.24435 3.61085 6.43961L2.99213 7.05833L2.94922 7.10124C2.57957 7.47077 2.34303 7.70724 2.17788 7.95035C1.50265 8.94432 1.4678 10.238 2.11799 11.2281C2.27656 11.4695 2.50766 11.7005 2.8591 12.0518L2.90374 12.0965L2.94837 12.1411C3.29967 12.4925 3.53068 12.7237 3.77214 12.8822C4.76219 13.5324 6.05589 13.4976 7.04986 12.8223C7.29296 12.6572 7.52943 12.4206 7.89896 12.051L7.89897 12.051L7.94188 12.0081L8.5606 11.3894C8.75586 11.1941 8.75586 10.8775 8.5606 10.6823C8.36533 10.487 8.04875 10.487 7.85349 10.6823L7.23477 11.301C6.80777 11.728 6.6423 11.8903 6.48794 11.9951C5.81158 12.4546 4.95642 12.4636 4.32107 12.0464C4.17681 11.9516 4.02274 11.8012 3.61085 11.3894C3.19896 10.9775 3.0486 10.8234 2.95385 10.6791C2.53661 10.0438 2.54561 9.18863 3.00507 8.51227C3.10993 8.35791 3.27224 8.19244 3.69924 7.76544L4.31796 7.14672ZM9.62172 6.08558C9.81698 5.89032 9.81698 5.57373 9.62172 5.37847C9.42646 5.18321 9.10988 5.18321 8.91461 5.37847L5.37908 8.91401C5.18382 9.10927 5.18382 9.42585 5.37908 9.62111C5.57434 9.81637 5.89092 9.81637 6.08619 9.62111L9.62172 6.08558Z\""
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": " fill=\"currentColor\""
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": " />"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "</svg>"
},
{
"type": "MessageLine",
"content": "No newline at end of file"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/link-2.svg"
},
{
"type": "AddedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 11
},
"fromFileRange": {
"start": 0,
"lines": 0
},
"changes": [
{
"type": "AddedLine",
"lineAfter": 1,
"content": "<!-- Downloaded from @radix-ui/icons -->"
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": "<!-- License https://github.com/radix-ui/icons/blob/master/LICENSE -->"
},
{
"type": "AddedLine",
"lineAfter": 3,
"content": "<!-- https://github.com/radix-ui/icons/blob/master/packages/radix-icons/icons/question-mark-circled.svg -->"
},
{
"type": "AddedLine",
"lineAfter": 4,
"content": "<svg width=\"15\" height=\"15\" viewBox=\"0 0 15 15\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">"
},
{
"type": "AddedLine",
"lineAfter": 5,
"content": " <path"
},
{
"type": "AddedLine",
"lineAfter": 6,
"content": " fill-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 7,
"content": " clip-rule=\"evenodd\""
},
{
"type": "AddedLine",
"lineAfter": 8,
"content": " d=\"M0.877075 7.49972C0.877075 3.84204 3.84222 0.876892 7.49991 0.876892C11.1576 0.876892 14.1227 3.84204 14.1227 7.49972C14.1227 11.1574 11.1576 14.1226 7.49991 14.1226C3.84222 14.1226 0.877075 11.1574 0.877075 7.49972ZM7.49991 1.82689C4.36689 1.82689 1.82708 4.36671 1.82708 7.49972C1.82708 10.6327 4.36689 13.1726 7.49991 13.1726C10.6329 13.1726 13.1727 10.6327 13.1727 7.49972C13.1727 4.36671 10.6329 1.82689 7.49991 1.82689ZM8.24993 10.5C8.24993 10.9142 7.91414 11.25 7.49993 11.25C7.08571 11.25 6.74993 10.9142 6.74993 10.5C6.74993 10.0858 7.08571 9.75 7.49993 9.75C7.91414 9.75 8.24993 10.0858 8.24993 10.5ZM6.05003 6.25C6.05003 5.57211 6.63511 4.925 7.50003 4.925C8.36496 4.925 8.95003 5.57211 8.95003 6.25C8.95003 6.74118 8.68002 6.99212 8.21447 7.27494C8.16251 7.30651 8.10258 7.34131 8.03847 7.37854L8.03841 7.37858C7.85521 7.48497 7.63788 7.61119 7.47449 7.73849C7.23214 7.92732 6.95003 8.23198 6.95003 8.7C6.95004 9.00376 7.19628 9.25 7.50004 9.25C7.8024 9.25 8.04778 9.00601 8.05002 8.70417L8.05056 8.7033C8.05924 8.6896 8.08493 8.65735 8.15058 8.6062C8.25207 8.52712 8.36508 8.46163 8.51567 8.37436L8.51571 8.37433C8.59422 8.32883 8.68296 8.27741 8.78559 8.21506C9.32004 7.89038 10.05 7.35382 10.05 6.25C10.05 4.92789 8.93511 3.825 7.50003 3.825C6.06496 3.825 4.95003 4.92789 4.95003 6.25C4.95003 6.55376 5.19628 6.8 5.50003 6.8C5.80379 6.8 6.05003 6.55376 6.05003 6.25Z\""
},
{
"type": "AddedLine",
"lineAfter": 9,
"content": " fill=\"currentColor\""
},
{
"type": "AddedLine",
"lineAfter": 10,
"content": " />"
},
{
"type": "AddedLine",
"lineAfter": 11,
"content": "</svg>"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/other/svg-icons/question-mark-circled.svg"
},
{
"type": "ChangedFile",
"chunks": [
{
"type": "Chunk",
"toFileRange": {
"start": 1,
"lines": 5
},
"fromFileRange": {
"start": 1,
"lines": 5
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 1,
"lineAfter": 1,
"content": "{"
},
{
"type": "DeletedLine",
"lineBefore": 2,
"content": " \"name\": \"exercises__sep__11.finish__sep__01.solution.finished\","
},
{
"type": "AddedLine",
"lineAfter": 2,
"content": " \"name\": \"exercises__sep__11.finish__sep__01.problem.finished\","
},
{
"type": "UnchangedLine",
"lineBefore": 3,
"lineAfter": 3,
"content": " \"private\": true,"
},
{
"type": "UnchangedLine",
"lineBefore": 4,
"lineAfter": 4,
"content": " \"sideEffects\": false,"
},
{
"type": "UnchangedLine",
"lineBefore": 5,
"lineAfter": 5,
"content": " \"type\": \"module\","
}
]
},
{
"type": "Chunk",
"toFileRange": {
"start": 18,
"lines": 7
},
"fromFileRange": {
"start": 18,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 18,
"lineAfter": 18,
"content": " \"dependencies\": {"
},
{
"type": "UnchangedLine",
"lineBefore": 19,
"lineAfter": 19,
"content": " \"@conform-to/react\": \"^0.7.3\","
},
{
"type": "UnchangedLine",
"lineBefore": 20,
"lineAfter": 20,
"content": " \"@conform-to/zod\": \"^0.7.3\","
},
{
"type": "AddedLine",
"lineAfter": 21,
"content": " \"@epic-web/totp\": \"1.0.4\","
},
{
"type": "UnchangedLine",
"lineBefore": 21,
"lineAfter": 22,
"content": " \"@kentcdodds/workshop-app\": \"^2.10.5\","
},
{
"type": "UnchangedLine",
"lineBefore": 22,
"lineAfter": 23,
"content": " \"@mswjs/data\": \"^0.13.0\","
},
{
"type": "UnchangedLine",
"lineBefore": 23,
"lineAfter": 24,
"content": " \"@paralleldrive/cuid2\": \"^2.2.1\","
}
]
},
{
"type": "Chunk",
"toFileRange": {
"start": 29,
"lines": 7
},
"fromFileRange": {
"start": 28,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 28,
"lineAfter": 29,
"content": " \"@radix-ui/react-label\": \"^2.0.2\","
},
{
"type": "UnchangedLine",
"lineBefore": 29,
"lineAfter": 30,
"content": " \"@radix-ui/react-slot\": \"^1.0.2\","
},
{
"type": "UnchangedLine",
"lineBefore": 30,
"lineAfter": 31,
"content": " \"@radix-ui/react-tooltip\": \"^1.0.6\","
},
{
"type": "AddedLine",
"lineAfter": 32,
"content": " \"@react-email/components\": \"^0.0.7\","
},
{
"type": "UnchangedLine",
"lineBefore": 31,
"lineAfter": 33,
"content": " \"@remix-run/css-bundle\": \"^1.19.0\","
},
{
"type": "UnchangedLine",
"lineBefore": 32,
"lineAfter": 34,
"content": " \"@remix-run/node\": \"^1.19.0\","
},
{
"type": "UnchangedLine",
"lineBefore": 33,
"lineAfter": 35,
"content": " \"@remix-run/react\": \"^1.19.0\","
}
]
},
{
"type": "Chunk",
"toFileRange": {
"start": 50,
"lines": 18
},
"fromFileRange": {
"start": 48,
"lines": 14
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 48,
"lineAfter": 50,
"content": " \"isbot\": \"^3.6.13\","
},
{
"type": "UnchangedLine",
"lineBefore": 49,
"lineAfter": 51,
"content": " \"morgan\": \"^1.10.0\","
},
{
"type": "UnchangedLine",
"lineBefore": 50,
"lineAfter": 52,
"content": " \"prisma\": \"^5.0.0\","
},
{
"type": "AddedLine",
"lineAfter": 53,
"content": " \"qrcode\": \"^1.5.3\","
},
{
"type": "UnchangedLine",
"lineBefore": 51,
"lineAfter": 54,
"content": " \"react\": \"^18.2.0\","
},
{
"type": "UnchangedLine",
"lineBefore": 52,
"lineAfter": 55,
"content": " \"react-dom\": \"^18.2.0\","
},
{
"type": "UnchangedLine",
"lineBefore": 53,
"lineAfter": 56,
"content": " \"remix-auth\": \"^3.5.0\","
},
{
"type": "UnchangedLine",
"lineBefore": 54,
"lineAfter": 57,
"content": " \"remix-auth-form\": \"^1.3.0\","
},
{
"type": "AddedLine",
"lineAfter": 58,
"content": " \"remix-auth-github\": \"^1.5.0\","
},
{
"type": "UnchangedLine",
"lineBefore": 55,
"lineAfter": 59,
"content": " \"remix-utils\": \"^6.6.0\","
},
{
"type": "AddedLine",
"lineAfter": 60,
"content": " \"sonner\": \"^0.6.0\","
},
{
"type": "UnchangedLine",
"lineBefore": 56,
"lineAfter": 61,
"content": " \"tailwind-merge\": \"^1.14.0\","
},
{
"type": "UnchangedLine",
"lineBefore": 57,
"lineAfter": 62,
"content": " \"tailwindcss\": \"^3.3.3\","
},
{
"type": "UnchangedLine",
"lineBefore": 58,
"lineAfter": 63,
"content": " \"tailwindcss-animate\": \"^1.0.6\","
},
{
"type": "AddedLine",
"lineAfter": 64,
"content": " \"thirty-two\": \"^1.0.2\","
},
{
"type": "UnchangedLine",
"lineBefore": 59,
"lineAfter": 65,
"content": " \"zod\": \"^3.21.4\""
},
{
"type": "UnchangedLine",
"lineBefore": 60,
"lineAfter": 66,
"content": " },"
},
{
"type": "UnchangedLine",
"lineBefore": 61,
"lineAfter": 67,
"content": " \"devDependencies\": {"
}
]
},
{
"type": "Chunk",
"toFileRange": {
"start": 81,
"lines": 8
},
"fromFileRange": {
"start": 75,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 75,
"lineAfter": 81,
"content": " \"@types/express\": \"^4.17.17\","
},
{
"type": "UnchangedLine",
"lineBefore": 76,
"lineAfter": 82,
"content": " \"@types/fs-extra\": \"^11.0.1\","
},
{
"type": "UnchangedLine",
"lineBefore": 77,
"lineAfter": 83,
"content": " \"@types/morgan\": \"^1.9.4\","
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": " \"@types/node\": \"^20.4.1\","
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": " \"@types/qrcode\": \"^1.5.1\","
},
{
"type": "UnchangedLine",
"lineBefore": 78,
"lineAfter": 86,
"content": " \"@types/react\": \"^18.2.15\","
},
{
"type": "UnchangedLine",
"lineBefore": 79,
"lineAfter": 87,
"content": " \"@types/react-dom\": \"^18.2.7\","
},
{
"type": "UnchangedLine",
"lineBefore": 80,
"lineAfter": 88,
"content": " \"@vitejs/plugin-react\": \"^4.0.4\","
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/package.json"
},
{
"type": "RenamedFile",
"pathAfter": "/var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/prisma/migrations/20230804054702_init/migration.sql",
"pathBefore": "/var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.solution.finished/rbb60w04h7/prisma/migrations/20230801024443_init/migration.sql",
"chunks": [
{
"context": "CREATE TABLE \"Role\" (",
"type": "Chunk",
"toFileRange": {
"start": 80,
"lines": 29
},
"fromFileRange": {
"start": 80,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 80,
"lineAfter": 80,
"content": " \"updatedAt\" DATETIME NOT NULL"
},
{
"type": "UnchangedLine",
"lineBefore": 81,
"lineAfter": 81,
"content": ");"
},
{
"type": "UnchangedLine",
"lineBefore": 82,
"lineAfter": 82,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 83,
"content": "-- CreateTable"
},
{
"type": "AddedLine",
"lineAfter": 84,
"content": "CREATE TABLE \"Verification\" ("
},
{
"type": "AddedLine",
"lineAfter": 85,
"content": " \"id\" TEXT NOT NULL PRIMARY KEY,"
},
{
"type": "AddedLine",
"lineAfter": 86,
"content": " \"createdAt\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
},
{
"type": "AddedLine",
"lineAfter": 87,
"content": " \"type\" TEXT NOT NULL,"
},
{
"type": "AddedLine",
"lineAfter": 88,
"content": " \"target\" TEXT NOT NULL,"
},
{
"type": "AddedLine",
"lineAfter": 89,
"content": " \"secret\" TEXT NOT NULL,"
},
{
"type": "AddedLine",
"lineAfter": 90,
"content": " \"algorithm\" TEXT NOT NULL,"
},
{
"type": "AddedLine",
"lineAfter": 91,
"content": " \"digits\" INTEGER NOT NULL,"
},
{
"type": "AddedLine",
"lineAfter": 92,
"content": " \"period\" INTEGER NOT NULL,"
},
{
"type": "AddedLine",
"lineAfter": 93,
"content": " \"expiresAt\" DATETIME"
},
{
"type": "AddedLine",
"lineAfter": 94,
"content": ");"
},
{
"type": "AddedLine",
"lineAfter": 95,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 96,
"content": "-- CreateTable"
},
{
"type": "AddedLine",
"lineAfter": 97,
"content": "CREATE TABLE \"GitHubConnection\" ("
},
{
"type": "AddedLine",
"lineAfter": 98,
"content": " \"id\" TEXT NOT NULL PRIMARY KEY,"
},
{
"type": "AddedLine",
"lineAfter": 99,
"content": " \"providerId\" TEXT NOT NULL,"
},
{
"type": "AddedLine",
"lineAfter": 100,
"content": " \"createdAt\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
},
{
"type": "AddedLine",
"lineAfter": 101,
"content": " \"updatedAt\" DATETIME NOT NULL,"
},
{
"type": "AddedLine",
"lineAfter": 102,
"content": " \"userId\" TEXT NOT NULL,"
},
{
"type": "AddedLine",
"lineAfter": 103,
"content": " CONSTRAINT \"GitHubConnection_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE"
},
{
"type": "AddedLine",
"lineAfter": 104,
"content": ");"
},
{
"type": "AddedLine",
"lineAfter": 105,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 83,
"lineAfter": 106,
"content": "-- CreateTable"
},
{
"type": "UnchangedLine",
"lineBefore": 84,
"lineAfter": 107,
"content": "CREATE TABLE \"_PermissionToRole\" ("
},
{
"type": "UnchangedLine",
"lineBefore": 85,
"lineAfter": 108,
"content": " \"A\" TEXT NOT NULL,"
}
]
},
{
"context": "CREATE UNIQUE INDEX \"Permission_action_entity_access_key\" ON \"Permission\"(\"actio",
"type": "Chunk",
"toFileRange": {
"start": 149,
"lines": 15
},
"fromFileRange": {
"start": 126,
"lines": 6
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 126,
"lineAfter": 149,
"content": "-- CreateIndex"
},
{
"type": "UnchangedLine",
"lineBefore": 127,
"lineAfter": 150,
"content": "CREATE UNIQUE INDEX \"Role_name_key\" ON \"Role\"(\"name\");"
},
{
"type": "UnchangedLine",
"lineBefore": 128,
"lineAfter": 151,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "-- CreateIndex"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": "CREATE UNIQUE INDEX \"Verification_target_type_key\" ON \"Verification\"(\"target\", \"type\");"
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": "-- CreateIndex"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": "CREATE UNIQUE INDEX \"GitHubConnection_providerId_key\" ON \"GitHubConnection\"(\"providerId\");"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": "-- CreateIndex"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": "CREATE UNIQUE INDEX \"GitHubConnection_providerId_userId_key\" ON \"GitHubConnection\"(\"providerId\", \"userId\");"
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 129,
"lineAfter": 161,
"content": "-- CreateIndex"
},
{
"type": "UnchangedLine",
"lineBefore": 130,
"lineAfter": 162,
"content": "CREATE UNIQUE INDEX \"_PermissionToRole_AB_unique\" ON \"_PermissionToRole\"(\"A\", \"B\");"
},
{
"type": "UnchangedLine",
"lineBefore": 131,
"lineAfter": 163,
"content": ""
}
]
}
]
},
{
"type": "ChangedFile",
"chunks": [
{
"context": "model User {",
"type": "Chunk",
"toFileRange": {
"start": 19,
"lines": 12
},
"fromFileRange": {
"start": 19,
"lines": 11
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 19,
"lineAfter": 19,
"content": " createdAt DateTime @default(now())"
},
{
"type": "UnchangedLine",
"lineBefore": 20,
"lineAfter": 20,
"content": " updatedAt DateTime @updatedAt"
},
{
"type": "UnchangedLine",
"lineBefore": 21,
"lineAfter": 21,
"content": ""
},
{
"type": "DeletedLine",
"lineBefore": 22,
"content": " image UserImage?"
},
{
"type": "DeletedLine",
"lineBefore": 23,
"content": " password Password?"
},
{
"type": "DeletedLine",
"lineBefore": 24,
"content": " notes Note[]"
},
{
"type": "DeletedLine",
"lineBefore": 25,
"content": " roles Role[]"
},
{
"type": "DeletedLine",
"lineBefore": 26,
"content": " sessions Session[]"
},
{
"type": "AddedLine",
"lineAfter": 22,
"content": " image UserImage?"
},
{
"type": "AddedLine",
"lineAfter": 23,
"content": " password Password?"
},
{
"type": "AddedLine",
"lineAfter": 24,
"content": " notes Note[]"
},
{
"type": "AddedLine",
"lineAfter": 25,
"content": " roles Role[]"
},
{
"type": "AddedLine",
"lineAfter": 26,
"content": " sessions Session[]"
},
{
"type": "AddedLine",
"lineAfter": 27,
"content": " gitHubConnections GitHubConnection[]"
},
{
"type": "UnchangedLine",
"lineBefore": 27,
"lineAfter": 28,
"content": "}"
},
{
"type": "UnchangedLine",
"lineBefore": 28,
"lineAfter": 29,
"content": ""
},
{
"type": "UnchangedLine",
"lineBefore": 29,
"lineAfter": 30,
"content": "model Note {"
}
]
},
{
"context": "model Role {",
"type": "Chunk",
"toFileRange": {
"start": 122,
"lines": 44
},
"fromFileRange": {
"start": 121,
"lines": 3
},
"changes": [
{
"type": "UnchangedLine",
"lineBefore": 121,
"lineAfter": 122,
"content": " users User[]"
},
{
"type": "UnchangedLine",
"lineBefore": 122,
"lineAfter": 123,
"content": " permissions Permission[]"
},
{
"type": "UnchangedLine",
"lineBefore": 123,
"lineAfter": 124,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 125,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 126,
"content": "model Verification {"
},
{
"type": "AddedLine",
"lineAfter": 127,
"content": " id String @id @default(cuid())"
},
{
"type": "AddedLine",
"lineAfter": 128,
"content": " createdAt DateTime @default(now())"
},
{
"type": "AddedLine",
"lineAfter": 129,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 130,
"content": " /// The type of verification, e.g. \"email\" or \"phone\""
},
{
"type": "AddedLine",
"lineAfter": 131,
"content": " type String"
},
{
"type": "AddedLine",
"lineAfter": 132,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 133,
"content": " /// The thing we're trying to verify, e.g. a user's email or phone number"
},
{
"type": "AddedLine",
"lineAfter": 134,
"content": " target String"
},
{
"type": "AddedLine",
"lineAfter": 135,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 136,
"content": " /// The secret key used to generate the otp"
},
{
"type": "AddedLine",
"lineAfter": 137,
"content": " secret String"
},
{
"type": "AddedLine",
"lineAfter": 138,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 139,
"content": " /// The algorithm used to generate the otp"
},
{
"type": "AddedLine",
"lineAfter": 140,
"content": " algorithm String"
},
{
"type": "AddedLine",
"lineAfter": 141,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 142,
"content": " /// The number of digits in the otp"
},
{
"type": "AddedLine",
"lineAfter": 143,
"content": " digits Int"
},
{
"type": "AddedLine",
"lineAfter": 144,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 145,
"content": " /// The number of seconds the otp is valid for"
},
{
"type": "AddedLine",
"lineAfter": 146,
"content": " period Int"
},
{
"type": "AddedLine",
"lineAfter": 147,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 148,
"content": " /// When it's safe to delete this verification"
},
{
"type": "AddedLine",
"lineAfter": 149,
"content": " expiresAt DateTime?"
},
{
"type": "AddedLine",
"lineAfter": 150,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 151,
"content": " @@unique([target, type])"
},
{
"type": "AddedLine",
"lineAfter": 152,
"content": "}"
},
{
"type": "AddedLine",
"lineAfter": 153,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 154,
"content": "model GitHubConnection {"
},
{
"type": "AddedLine",
"lineAfter": 155,
"content": " id String @id @default(cuid())"
},
{
"type": "AddedLine",
"lineAfter": 156,
"content": " providerId String @unique"
},
{
"type": "AddedLine",
"lineAfter": 157,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 158,
"content": " createdAt DateTime @default(now())"
},
{
"type": "AddedLine",
"lineAfter": 159,
"content": " updatedAt DateTime @updatedAt"
},
{
"type": "AddedLine",
"lineAfter": 160,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 161,
"content": " user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)"
},
{
"type": "AddedLine",
"lineAfter": 162,
"content": " userId String"
},
{
"type": "AddedLine",
"lineAfter": 163,
"content": ""
},
{
"type": "AddedLine",
"lineAfter": 164,
"content": " @@unique([providerId, userId])"
},
{
"type": "AddedLine",
"lineAfter": 165,
"content": "}"
}
]
}
],
"path": "var/folders/kt/zd3bfncd0c3gjx25hbcq483c0000gn/T/kcdshop/diff/full-stack-testing/exercises__sep__11.finish__sep__01.problem.finished/rbb60w04h7/prisma/schema.prisma"
}
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment