diff --git a/extra/04.with-accounts/.env.example b/extra/04.with-accounts/.env.example index a1432f9..a85a915 100644 --- a/extra/04.with-accounts/.env.example +++ b/extra/04.with-accounts/.env.example @@ -4,3 +4,6 @@ # Optional (preview uses request origin by default) APP_BASE_URL= +# Required for signed host session cookies +COOKIE_SECRET=dev-cookie-secret + diff --git a/extra/04.with-accounts/README.mdx b/extra/04.with-accounts/README.mdx index 12b873a..3e5ea34 100644 --- a/extra/04.with-accounts/README.mdx +++ b/extra/04.with-accounts/README.mdx @@ -1,3 +1,3 @@ # With Accounts -This is the project after you've added support for accounts. +This is the project after the narrow host-account rollout from exercise 3 step 3. diff --git a/extra/04.with-accounts/client/app.tsx b/extra/04.with-accounts/client/app.tsx index 09786f1..65af847 100644 --- a/extra/04.with-accounts/client/app.tsx +++ b/extra/04.with-accounts/client/app.tsx @@ -7,6 +7,12 @@ import { visuallyHiddenCss } from './styles/visually-hidden.ts' function getRouteAnnouncement(pathname: string) { const segments = pathname.split('/').filter(Boolean) if (segments.length === 0) return 'Create schedule page loaded.' + if (segments[0] === 'login') return 'Host login page loaded.' + if (segments[0] === 'account' && segments[1] === 'schedules') { + return segments.length >= 3 + ? 'Saved host dashboard loaded.' + : 'Your schedules page loaded.' + } if (segments[0] === 's' && segments.length >= 3) return 'Host dashboard loaded.' if (segments[0] === 's' && segments.length >= 2) { @@ -150,6 +156,9 @@ export function App(handle: Handle) { New schedule + + Your schedules + How it works diff --git a/extra/04.with-accounts/client/routes/account-schedules.tsx b/extra/04.with-accounts/client/routes/account-schedules.tsx new file mode 100644 index 0000000..b2c93a6 --- /dev/null +++ b/extra/04.with-accounts/client/routes/account-schedules.tsx @@ -0,0 +1,191 @@ +import { type Handle } from 'remix/component' +import { navigate } from '#client/client-router.tsx' +import { setDocumentTitle, toAppTitle } from '#client/document-title.ts' +import { + colors, + radius, + shadows, + spacing, + typography, +} from '#client/styles/tokens.ts' + +type HostScheduleSummary = { + shareToken: string + title: string + createdAt: string + claimedAt: string | null +} + +function getLocationKey() { + if (typeof window === 'undefined') return '/account/schedules' + return `${window.location.pathname}${window.location.search}` +} + +export function AccountSchedulesRoute(handle: Handle) { + let lastLocationKey = '' + let isLoading = true + let email = '' + let errorMessage: string | null = null + let schedules: Array = [] + + handle.queueTask(async () => { + const nextLocationKey = getLocationKey() + if (nextLocationKey === lastLocationKey) return + lastLocationKey = nextLocationKey + isLoading = true + errorMessage = null + handle.update() + + try { + const response = await fetch('/api/account/schedules', { + headers: { Accept: 'application/json' }, + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + email?: string + schedules?: Array + error?: string + } | null + if (handle.signal.aborted || nextLocationKey !== lastLocationKey) return + if (response.status === 401) { + navigate( + `/login?redirectTo=${encodeURIComponent('/account/schedules')}`, + ) + return + } + if (!response.ok || !payload?.ok || !Array.isArray(payload.schedules)) { + errorMessage = + typeof payload?.error === 'string' + ? payload.error + : 'Unable to load your schedules.' + isLoading = false + handle.update() + return + } + email = payload.email ?? '' + schedules = payload.schedules + isLoading = false + handle.update() + } catch { + if (handle.signal.aborted || nextLocationKey !== lastLocationKey) return + errorMessage = 'Network error while loading your schedules.' + isLoading = false + handle.update() + } + }) + + return () => { + setDocumentTitle(toAppTitle('Your schedules')) + + return ( +
+
+

+ Your schedules +

+

+ Open any schedule you have already claimed + {email ? ` as ${email}` : ''}. +

+
+ +
+ Create a new schedule +
+ +
+
+ + {isLoading ? ( +

+ Loading your schedules… +

+ ) : errorMessage ? ( +

+ {errorMessage} +

+ ) : schedules.length === 0 ? ( +
+

+ No claimed schedules yet. +

+

+ Open a private host link, then save that schedule to your account. +

+
+ ) : ( +
+ {schedules.map((schedule) => ( + + ))} +
+ )} +
+ ) + } +} diff --git a/extra/04.with-accounts/client/routes/index.tsx b/extra/04.with-accounts/client/routes/index.tsx index efbe225..7168db9 100644 --- a/extra/04.with-accounts/client/routes/index.tsx +++ b/extra/04.with-accounts/client/routes/index.tsx @@ -1,9 +1,14 @@ +import { AccountSchedulesRoute } from './account-schedules.tsx' import { HomeRoute } from './home.tsx' +import { LoginRoute } from './login.tsx' import { ScheduleRoute } from './schedule.tsx' import { ScheduleHostRoute } from './schedule-host.tsx' export const clientRoutes = { '/': , + '/login': , + '/account/schedules': , + '/account/schedules/:shareToken': , '/s/:shareToken/:hostAccessToken': , '/s/:shareToken': , } diff --git a/extra/04.with-accounts/client/routes/login.tsx b/extra/04.with-accounts/client/routes/login.tsx new file mode 100644 index 0000000..724ca72 --- /dev/null +++ b/extra/04.with-accounts/client/routes/login.tsx @@ -0,0 +1,308 @@ +import { type Handle } from 'remix/component' +import { navigate } from '#client/client-router.tsx' +import { setDocumentTitle, toAppTitle } from '#client/document-title.ts' +import { + colors, + radius, + shadows, + spacing, + typography, +} from '#client/styles/tokens.ts' + +function normalizeRedirectTo(value: string | null) { + if (!value) return '/account/schedules' + if (!value.startsWith('/')) return '/account/schedules' + if (value.startsWith('//')) return '/account/schedules' + return value +} + +function getLocationKey() { + if (typeof window === 'undefined') return '/login' + return `${window.location.pathname}${window.location.search}` +} + +function getErrorMessage(code: string | null) { + if (code === 'expired-link') { + return 'That sign-in link expired. Request a fresh link below.' + } + if (code === 'invalid-link') { + return 'That sign-in link is invalid. Request a fresh link below.' + } + return null +} + +export function LoginRoute(handle: Handle) { + let lastLocationKey = '' + let redirectTo = '/account/schedules' + let email = '' + let isCheckingSession = true + let isSubmitting = false + let statusMessage: string | null = null + let statusIsError = false + let loginLink: string | null = null + let expiresAt: string | null = null + + async function copyLoginLink() { + if (!loginLink || typeof navigator === 'undefined') return + try { + await navigator.clipboard.writeText(loginLink) + statusMessage = 'Sign-in link copied.' + statusIsError = false + handle.update() + } catch { + statusMessage = 'Unable to copy the sign-in link.' + statusIsError = true + handle.update() + } + } + + async function requestLoginLink() { + const normalizedEmail = email.trim() + if (!normalizedEmail) { + statusMessage = 'Enter your email address to continue.' + statusIsError = true + handle.update() + return + } + + isSubmitting = true + statusMessage = 'Creating sign-in link…' + statusIsError = false + loginLink = null + expiresAt = null + handle.update() + + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: normalizedEmail, + redirectTo, + }), + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + email?: string + loginLink?: string + expiresAt?: string + error?: string + } | null + + if ( + !response.ok || + !payload?.ok || + typeof payload.loginLink !== 'string' || + typeof payload.expiresAt !== 'string' + ) { + statusMessage = + typeof payload?.error === 'string' + ? payload.error + : 'Unable to create a sign-in link.' + statusIsError = true + handle.update() + return + } + + email = payload.email ?? normalizedEmail + loginLink = payload.loginLink + expiresAt = payload.expiresAt + statusMessage = 'Open the one-time sign-in link below.' + statusIsError = false + handle.update() + } catch { + statusMessage = 'Network error while creating a sign-in link.' + statusIsError = true + handle.update() + } finally { + isSubmitting = false + handle.update() + } + } + + handle.queueTask(async () => { + const nextLocationKey = getLocationKey() + if (nextLocationKey === lastLocationKey) return + lastLocationKey = nextLocationKey + const url = new URL( + typeof window === 'undefined' ? 'https://example.com/login' : window.location.href, + ) + redirectTo = normalizeRedirectTo(url.searchParams.get('redirectTo')) + statusMessage = getErrorMessage(url.searchParams.get('error')) + statusIsError = statusMessage !== null + loginLink = null + expiresAt = null + isCheckingSession = true + handle.update() + + try { + const response = await fetch('/api/session', { + headers: { Accept: 'application/json' }, + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + authenticated?: boolean + } | null + if (handle.signal.aborted || nextLocationKey !== lastLocationKey) return + if (response.ok && payload?.ok && payload.authenticated) { + navigate(redirectTo) + return + } + } catch { + if (handle.signal.aborted || nextLocationKey !== lastLocationKey) return + } + + isCheckingSession = false + handle.update() + }) + + return () => { + setDocumentTitle(toAppTitle('Host login')) + + return ( +
+
+

+ Host login +

+

+ Use a one-time sign-in link to reopen schedules you have claimed. +

+
+ +
+ + +
+ + Your schedules +
+ +

+ {statusMessage ?? '\u00a0'} +

+ + {loginLink ? ( +
+

+ One-time sign-in link for {email} +

+ + {loginLink} + +
+ + Open sign-in link + + +
+

+ Link expires at {new Date(expiresAt ?? '').toLocaleString()}. +

+
+ ) : null} +
+
+ ) + } +} diff --git a/extra/04.with-accounts/client/routes/schedule-host.tsx b/extra/04.with-accounts/client/routes/schedule-host.tsx index 2a0c16c..7dc2aa8 100644 --- a/extra/04.with-accounts/client/routes/schedule-host.tsx +++ b/extra/04.with-accounts/client/routes/schedule-host.tsx @@ -1,5 +1,6 @@ import { type Handle } from 'remix/component' import { getBrowserTimeZone } from '#client/browser-time-zone.ts' +import { navigate } from '#client/client-router.tsx' import { setDocumentTitle, toAppTitle } from '#client/document-title.ts' import { renderScheduleGrid } from '#client/components/schedule-grid.tsx' import { createPointerDragSelectionController } from '#client/pointer-drag-selection.ts' @@ -101,20 +102,51 @@ function measureNamePillWidthPx(text: string) { return Math.ceil(element.getBoundingClientRect().width) } -function parseHostRouteParams(pathname: string) { +type HostRouteAccess = + | { + mode: 'host-link' + shareToken: string + hostAccessToken: string + } + | { + mode: 'account' + shareToken: string + hostAccessToken: '' + } + +type AuthSession = { + id: string + email: string +} + +function parseHostRouteParams(pathname: string): HostRouteAccess | null { const segments = pathname.split('/').filter(Boolean) - if (segments.length !== 3) return null - if (segments[0] !== 's') return null - let shareToken = '' - let hostAccessToken = '' - try { - shareToken = decodeURIComponent(segments[1] ?? '').trim() - hostAccessToken = decodeURIComponent(segments[2] ?? '').trim() - } catch { - return null + if (segments[0] === 's' && segments.length === 3) { + let shareToken = '' + let hostAccessToken = '' + try { + shareToken = decodeURIComponent(segments[1] ?? '').trim() + hostAccessToken = decodeURIComponent(segments[2] ?? '').trim() + } catch { + return null + } + if (!shareToken || !hostAccessToken) return null + return { mode: 'host-link', shareToken, hostAccessToken } + } + if ( + segments[0] === 'account' && + segments[1] === 'schedules' && + segments.length === 3 + ) { + try { + const shareToken = decodeURIComponent(segments[2] ?? '').trim() + if (!shareToken) return null + return { mode: 'account', shareToken, hostAccessToken: '' } + } catch { + return null + } } - if (!shareToken || !hostAccessToken) return null - return { shareToken, hostAccessToken } + return null } function getPathname() { @@ -122,6 +154,11 @@ function getPathname() { return window.location.pathname } +function getPathWithSearch() { + if (typeof window === 'undefined') return '/' + return `${window.location.pathname}${window.location.search}` +} + function isMobileViewport() { if (typeof window === 'undefined') return false if (typeof window.matchMedia !== 'function') return false @@ -430,8 +467,14 @@ function focusSubmissionEditButton(attendeeId: string) { export function ScheduleHostRoute(handle: Handle) { const browserTimeZone = getBrowserTimeZone() + let accessMode: HostRouteAccess['mode'] = 'host-link' let shareToken = '' let hostAccessToken = '' + let authSession: AuthSession | null = null + let authSessionLoaded = false + let isClaimingSchedule = false + let claimMessage: string | null = null + let claimMessageIsError = false let snapshot: ScheduleSnapshot | null = null let hostNameDraft = '' let titleDraft = '' @@ -927,14 +970,98 @@ export function ScheduleHostRoute(handle: Handle) { handle.update() } - async function loadSnapshot() { + function createHostAuthHeaders(init: HeadersInit = {}) { + const headers = new Headers(init) + if (hostAccessToken) { + headers.set('X-Host-Token', hostAccessToken) + } + return headers + } + + async function loadAuthSession() { + const requestShareToken = shareToken + try { + const response = await fetch('/api/session', { + headers: { Accept: 'application/json' }, + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + authenticated?: boolean + session?: AuthSession | null + } | null + if (requestShareToken !== shareToken || handle.signal.aborted) return + authSession = + response.ok && payload?.ok && payload.authenticated && payload.session + ? payload.session + : null + } catch { + if (requestShareToken !== shareToken || handle.signal.aborted) return + authSession = null + } + authSessionLoaded = true + if (accessMode === 'account' && !authSession) { + navigate(`/login?redirectTo=${encodeURIComponent(getPathWithSearch())}`) + return + } + handle.update() + } + + async function claimSchedule() { const requestShareToken = shareToken const requestHostAccessToken = hostAccessToken - if ( - !requestShareToken || - !requestHostAccessToken || - handle.signal.aborted - ) { + if (!requestShareToken || !requestHostAccessToken || isClaimingSchedule) return + isClaimingSchedule = true + claimMessage = null + claimMessageIsError = false + handle.update() + try { + const response = await fetch(`/api/schedules/${requestShareToken}/claim`, { + method: 'POST', + headers: createHostAuthHeaders({ + 'Content-Type': 'application/json', + }), + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + snapshot?: ScheduleSnapshot + error?: string + } | null + if (requestShareToken !== shareToken || handle.signal.aborted) return + if (!response.ok || !payload?.ok || !payload.snapshot) { + claimMessage = + typeof payload?.error === 'string' + ? payload.error + : response.status === 401 + ? 'Sign in to save this schedule to your account.' + : 'Unable to save this schedule to your account.' + claimMessageIsError = true + if (response.status === 401) { + navigate(`/login?redirectTo=${encodeURIComponent(getPathWithSearch())}`) + return + } + handle.update() + return + } + applySnapshot(payload.snapshot) + claimMessage = 'Saved to your account. You can reopen it from Your schedules.' + claimMessageIsError = false + handle.update() + } catch { + if (requestShareToken !== shareToken || handle.signal.aborted) return + claimMessage = 'Network error while saving this schedule to your account.' + claimMessageIsError = true + handle.update() + } finally { + if (requestShareToken === shareToken && !handle.signal.aborted) { + isClaimingSchedule = false + handle.update() + } + } + } + + async function loadSnapshot() { + const requestShareToken = shareToken + if (!requestShareToken || handle.signal.aborted) { return } const requestId = ++snapshotRequestId @@ -942,10 +1069,9 @@ export function ScheduleHostRoute(handle: Handle) { const response = await fetch( `/api/schedules/${requestShareToken}/host-snapshot`, { - headers: { + headers: createHostAuthHeaders({ Accept: 'application/json', - 'X-Host-Token': requestHostAccessToken, - }, + }), }, ) const payload = (await response.json().catch(() => null)) as { @@ -961,10 +1087,15 @@ export function ScheduleHostRoute(handle: Handle) { return } if (!response.ok || !payload?.ok || !payload.snapshot) { + if (accessMode === 'account' && response.status === 401) { + navigate(`/login?redirectTo=${encodeURIComponent(getPathWithSearch())}`) + return + } const errorText = typeof payload?.error === 'string' ? payload.error - : response.status === 401 || response.status === 403 + : accessMode === 'host-link' && + (response.status === 401 || response.status === 403) ? 'Invalid host dashboard link.' : 'Unable to load host dashboard.' setStatus(errorText, true) @@ -1038,11 +1169,6 @@ export function ScheduleHostRoute(handle: Handle) { pendingSave = true return } - const requestHostAccessToken = hostAccessToken - if (!requestHostAccessToken) { - setStatus('Host access token missing.', true) - return - } const hostName = normalizeName(hostNameDraft) if (!hostName) { setStatus('Host name is required.', true) @@ -1095,10 +1221,9 @@ export function ScheduleHostRoute(handle: Handle) { } const response = await fetch(`/api/schedules/${requestShareToken}/host`, { method: 'POST', - headers: { + headers: createHostAuthHeaders({ 'Content-Type': 'application/json', - 'X-Host-Token': requestHostAccessToken, - }, + }), body: JSON.stringify(body), }) const payload = (await response.json().catch(() => null)) as { @@ -1152,14 +1277,6 @@ export function ScheduleHostRoute(handle: Handle) { ) { const requestShareToken = shareToken if (!requestShareToken || handle.signal.aborted) return - const requestHostAccessToken = hostAccessToken - if (!requestHostAccessToken) { - const errorText = 'Host access token missing.' - submissionErrorById.set(attendeeId, errorText) - setStatus(errorText, true) - handle.update() - return - } if (!normalizeName(nextSubmissionName)) { submissionErrorById.set(attendeeId, 'Submission name is required.') handle.update() @@ -1172,10 +1289,9 @@ export function ScheduleHostRoute(handle: Handle) { try { const response = await fetch(`/api/schedules/${requestShareToken}/host`, { method: 'POST', - headers: { + headers: createHostAuthHeaders({ 'Content-Type': 'application/json', - 'X-Host-Token': requestHostAccessToken, - }, + }), body: JSON.stringify({ submissionId: attendeeId, submissionName: nextSubmissionName, @@ -1219,14 +1335,6 @@ export function ScheduleHostRoute(handle: Handle) { async function deleteSubmission(attendeeId: string) { const requestShareToken = shareToken if (!requestShareToken || handle.signal.aborted) return - const requestHostAccessToken = hostAccessToken - if (!requestHostAccessToken) { - const errorText = 'Host access token missing.' - submissionErrorById.set(attendeeId, errorText) - setStatus(errorText, true) - handle.update() - return - } if (submissionActionById.has(attendeeId)) return submissionErrorById.delete(attendeeId) submissionActionById.set(attendeeId, 'delete') @@ -1234,10 +1342,9 @@ export function ScheduleHostRoute(handle: Handle) { try { const response = await fetch(`/api/schedules/${requestShareToken}/host`, { method: 'POST', - headers: { + headers: createHostAuthHeaders({ 'Content-Type': 'application/json', - 'X-Host-Token': requestHostAccessToken, - }, + }), body: JSON.stringify({ submissionId: attendeeId, deleteSubmission: true, @@ -1663,8 +1770,14 @@ export function ScheduleHostRoute(handle: Handle) { clearSocketResources() clearRefreshTimer() const routeParams = parseHostRouteParams(nextPathname) + accessMode = routeParams?.mode ?? 'host-link' shareToken = routeParams?.shareToken ?? '' hostAccessToken = routeParams?.hostAccessToken ?? '' + authSession = null + authSessionLoaded = false + isClaimingSchedule = false + claimMessage = null + claimMessageIsError = false snapshot = null hostNameDraft = '' titleDraft = '' @@ -1699,14 +1812,15 @@ export function ScheduleHostRoute(handle: Handle) { pendingSave = false connectionState = 'offline' setStatus(null, false) + void loadAuthSession() await loadSnapshot() - if (shareToken && hostAccessToken) { + if (shareToken) { connectSocket() } }) return () => { - if (!shareToken || !hostAccessToken) { + if (!shareToken) { setDocumentTitle(toAppTitle('Host dashboard not found')) return (
@@ -1900,10 +2014,18 @@ export function ScheduleHostRoute(handle: Handle) { const appOrigin = typeof window === 'undefined' ? '' : window.location.origin const attendeePath = `/s/${encodeURIComponent(shareToken)}` - const hostPath = `${attendeePath}/${encodeURIComponent(hostAccessToken)}` + const hostPath = hostAccessToken + ? `${attendeePath}/${encodeURIComponent(hostAccessToken)}` + : null const attendeeUrl = appOrigin ? `${appOrigin}${attendeePath}` : attendeePath - const hostUrl = appOrigin ? `${appOrigin}${hostPath}` : hostPath + const hostUrl = hostPath ? (appOrigin ? `${appOrigin}${hostPath}` : hostPath) : null const scheduleTitle = currentSnapshot?.schedule.title.trim() ?? '' + const ownerUserId = currentSnapshot?.schedule.ownerUserId ?? null + const isOwnedByCurrentSession = + ownerUserId !== null && authSession?.id === ownerUserId + const isClaimedByAnotherAccount = + ownerUserId !== null && authSession !== null && authSession.id !== ownerUserId + const loginRedirectPath = `/login?redirectTo=${encodeURIComponent(getPathWithSearch())}` if (isLoading && !currentSnapshot) { setDocumentTitle(toAppTitle('Loading host dashboard')) @@ -2017,56 +2139,167 @@ export function ScheduleHostRoute(handle: Handle) { -
-

- Host dashboard link -

-
- - {hostUrl} - - + + {hostUrl} + + +
- + ) : ( +
+

+ Private host-link fallback +

+

+ You are using logged-in host access. Your original private + host link still works if you saved it. +

+
+ )} + +
+

+ Returning host access +

+ {accessMode === 'account' ? ( +

+ You are managing this schedule from your account + {authSession ? ` (${authSession.email})` : ''}. The private host + link still works as a fallback. +

+ ) : isOwnedByCurrentSession ? ( +

+ This schedule is already saved to your account. Open it any time + from Your schedules or keep + using the private host link. +

+ ) : isClaimedByAnotherAccount ? ( +

+ This private host link already belongs to a different account. + You can still use the link directly. +

+ ) : authSession ? ( +
+

+ Signed in as {authSession.email}. Save this schedule so you can + reopen it from Your schedules. +

+
+ + Your schedules +
+
+ ) : authSessionLoaded ? ( +
+

+ Want an easier return path? Sign in, then save this schedule to + your account without changing attendee access. +

+ +
+ ) : ( +

+ Checking whether you are already signed in… +

+ )} +

+ {claimMessage ?? '\u00a0'} +

{ + const scheduleTitle = `Planning session ${Date.now()}` + await page.goto('/') + await page.getByLabel('Schedule title').fill(scheduleTitle) + await page.getByLabel('Your name').fill('Host Original') + await page.getByRole('button', { name: 'Create share link' }).click() + await expect(page).toHaveURL(/\/s\/[a-z0-9]+\/[a-z0-9]+$/i) + + const { shareToken, hostAccessToken } = parseHostRouteTokens(page.url()) + expect(shareToken).not.toBe('') + expect(hostAccessToken).not.toBe('') + if (!shareToken || !hostAccessToken) { + throw new Error('Expected private host route tokens after schedule creation.') + } + + await expect( + page.getByRole('link', { name: 'Sign in to save this schedule' }), + ).toBeVisible() + await page.getByRole('link', { name: 'Sign in to save this schedule' }).click() + await expect(page).toHaveURL(/\/login/) + + await page.getByLabel('Email address').fill('host@example.com') + await page.getByRole('button', { name: 'Create sign-in link' }).click() + const openLink = page.getByRole('link', { name: 'Open sign-in link' }) + await expect(openLink).toBeVisible() + await openLink.click() + + await expect(page).toHaveURL( + new RegExp(`/s/${shareToken}/${hostAccessToken}$`, 'i'), + ) + await expect( + page.getByRole('button', { name: 'Save this schedule to your account' }), + ).toBeVisible() + await page + .getByRole('button', { name: 'Save this schedule to your account' }) + .click() + await expect( + page.getByText('Saved to your account. You can reopen it from Your schedules.'), + ).toBeVisible() + + await page + .locator('#main-content') + .getByRole('link', { name: 'Your schedules' }) + .click() + await expect(page).toHaveURL('/account/schedules') + await expect(page.getByRole('heading', { name: 'Your schedules' })).toBeVisible() + await expect(page.getByText(scheduleTitle)).toBeVisible() + + await page + .getByRole('article') + .filter({ hasText: scheduleTitle }) + .getByRole('link', { name: 'Open dashboard' }) + .click() + await expect(page).toHaveURL(`/account/schedules/${shareToken}`) + await expect( + page.getByText(/You are managing this schedule from your account/), + ).toBeVisible() + + await page.getByLabel('Edit host name for Host Original').click() + const hostNameInput = page.getByLabel( + 'Submission name input for Host Original', + ) + await expect(hostNameInput).toBeVisible() + await hostNameInput.fill('Host Account') + await hostNameInput.press('Enter') + await expect(page.getByText('Host settings synced.')).toBeVisible() + await expect( + page.getByLabel('Edit host name for Host Account'), + ).toBeVisible() + + await page.reload() + await expect(page).toHaveURL(`/account/schedules/${shareToken}`) + await expect( + page.getByLabel('Edit host name for Host Account'), + ).toBeVisible() +}) diff --git a/extra/04.with-accounts/migrations/0006-host-accounts.sql b/extra/04.with-accounts/migrations/0006-host-accounts.sql new file mode 100644 index 0000000..fd8b4c4 --- /dev/null +++ b/extra/04.with-accounts/migrations/0006-host-accounts.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, + email TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS host_login_tokens ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_host_login_tokens_user_id + ON host_login_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_host_login_tokens_expires_at + ON host_login_tokens(expires_at); + +ALTER TABLE schedules ADD COLUMN owner_user_id TEXT; +ALTER TABLE schedules ADD COLUMN claimed_at TEXT; + +CREATE INDEX IF NOT EXISTS idx_schedules_owner_user_id + ON schedules(owner_user_id); diff --git a/extra/04.with-accounts/server/auth-redirect.ts b/extra/04.with-accounts/server/auth-redirect.ts new file mode 100644 index 0000000..a95bab3 --- /dev/null +++ b/extra/04.with-accounts/server/auth-redirect.ts @@ -0,0 +1,27 @@ +function normalizeRedirectTo(value: string | null) { + if (!value) return null + if (!value.startsWith('/')) return null + if (value.startsWith('//')) return null + return value +} + +export function normalizeRedirectPath( + value: string | null | undefined, + fallback = '/account/schedules', +) { + return normalizeRedirectTo(value ?? null) ?? fallback +} + +export function redirectToLogin( + request: Request, + options: { redirectTo?: string } = {}, +) { + const requestUrl = new URL(request.url) + const target = normalizeRedirectPath( + options.redirectTo ?? `${requestUrl.pathname}${requestUrl.search}`, + '/account/schedules', + ) + const loginUrl = new URL('/login', requestUrl) + loginUrl.searchParams.set('redirectTo', target) + return Response.redirect(loginUrl, 302) +} diff --git a/extra/04.with-accounts/server/auth-session.ts b/extra/04.with-accounts/server/auth-session.ts new file mode 100644 index 0000000..c18a2ad --- /dev/null +++ b/extra/04.with-accounts/server/auth-session.ts @@ -0,0 +1,80 @@ +import { createCookie } from '@remix-run/cookie' + +const sessionMaxAgeSeconds = 60 * 60 * 24 * 7 + +export type AuthSession = { + id: string + email: string +} + +let sessionCookie: ReturnType | null = null +let sessionSecret: string | null = null + +export function setAuthSessionSecret(secret: string) { + if (!secret) { + throw new Error('Missing COOKIE_SECRET for session signing.') + } + + if (sessionCookie && sessionSecret === secret) { + return + } + + sessionSecret = secret + sessionCookie = createCookie('epic-scheduler_session', { + httpOnly: true, + sameSite: 'Lax', + path: '/', + maxAge: sessionMaxAgeSeconds, + secrets: [secret], + }) +} + +function getSessionCookie() { + if (!sessionCookie) { + throw new Error('Session cookie not configured. Call setAuthSessionSecret.') + } + + return sessionCookie +} + +function isAuthSession(value: unknown): value is AuthSession { + if (!value || typeof value !== 'object') return false + const record = value as Record + return ( + typeof record.id === 'string' && + record.id.length > 0 && + typeof record.email === 'string' && + record.email.length > 0 + ) +} + +export async function createAuthCookie(session: AuthSession, secure: boolean) { + return getSessionCookie().serialize(JSON.stringify(session), { secure }) +} + +export async function destroyAuthCookie(secure: boolean) { + return getSessionCookie().serialize('', { + secure, + maxAge: 0, + expires: new Date(0), + }) +} + +export async function readAuthSession(request: Request) { + const cookieHeader = request.headers.get('Cookie') + if (!cookieHeader) return null + + const stored = await getSessionCookie().parse(cookieHeader) + if (!stored || typeof stored !== 'string') return null + + try { + const parsed = JSON.parse(stored) + if (isAuthSession(parsed)) { + return parsed + } + } catch { + return null + } + + return null +} diff --git a/extra/04.with-accounts/server/handler.ts b/extra/04.with-accounts/server/handler.ts index a38cbfe..a511bdc 100644 --- a/extra/04.with-accounts/server/handler.ts +++ b/extra/04.with-accounts/server/handler.ts @@ -1,9 +1,11 @@ +import { setAuthSessionSecret } from './auth-session.ts' import { getEnv } from './env.ts' import { createAppRouter } from './router.ts' export async function handleRequest(request: Request, env: Env) { try { const appEnv = getEnv(env) + setAuthSessionSecret(appEnv.COOKIE_SECRET) const router = createAppRouter(appEnv) return await router.fetch(request) } catch (error) { diff --git a/extra/04.with-accounts/server/handlers/account-schedules-read.ts b/extra/04.with-accounts/server/handlers/account-schedules-read.ts new file mode 100644 index 0000000..d514b74 --- /dev/null +++ b/extra/04.with-accounts/server/handlers/account-schedules-read.ts @@ -0,0 +1,32 @@ +import { type BuildAction } from 'remix/fetch-router' +import { readAuthSession } from '#server/auth-session.ts' +import { listSchedulesOwnedByUser } from '#shared/schedule-store.ts' +import { type AppEnv } from '#types/env-schema.ts' +import { type routes } from '#server/routes.ts' + +export function createAccountSchedulesReadHandler( + appEnv: Pick, +) { + return { + middleware: [], + async action({ request }) { + const session = await readAuthSession(request) + if (!session) { + return Response.json( + { ok: false, error: 'Sign in required.' }, + { status: 401 }, + ) + } + + const schedules = await listSchedulesOwnedByUser(appEnv.APP_DB, session.id) + return Response.json({ + ok: true, + email: session.email, + schedules, + }) + }, + } satisfies BuildAction< + typeof routes.accountSchedulesRead.method, + typeof routes.accountSchedulesRead.pattern + > +} diff --git a/extra/04.with-accounts/server/handlers/app-pages.ts b/extra/04.with-accounts/server/handlers/app-pages.ts index ad29c7d..3ea6795 100644 --- a/extra/04.with-accounts/server/handlers/app-pages.ts +++ b/extra/04.with-accounts/server/handlers/app-pages.ts @@ -1,8 +1,67 @@ import { type BuildAction } from 'remix/fetch-router' +import { readAuthSession } from '#server/auth-session.ts' +import { redirectToLogin } from '#server/auth-redirect.ts' import { Layout } from '#server/layout.ts' import { render } from '#server/render.ts' import { type routes } from '#server/routes.ts' +export const loginPage = { + middleware: [], + async action({ request }) { + const session = await readAuthSession(request) + if (session) { + return Response.redirect(new URL('/account/schedules', request.url), 302) + } + return render( + Layout({ + title: 'Host login | Epic Scheduler', + description: + 'Use a one-time sign-in link to get back to the schedules you have claimed.', + }), + ) + }, +} satisfies BuildAction + +export const accountSchedulesPage = { + middleware: [], + async action({ request }) { + const session = await readAuthSession(request) + if (!session) { + return redirectToLogin(request) + } + return render( + Layout({ + title: 'Your schedules | Epic Scheduler', + description: + 'Open the schedules you have claimed without hunting for a saved host link.', + }), + ) + }, +} satisfies BuildAction< + typeof routes.accountSchedulesPage.method, + typeof routes.accountSchedulesPage.pattern +> + +export const accountSchedulePage = { + middleware: [], + async action({ request }) { + const session = await readAuthSession(request) + if (!session) { + return redirectToLogin(request) + } + return render( + Layout({ + title: 'Saved host dashboard | Epic Scheduler', + description: + 'Manage a claimed schedule from your account while preserving host-link fallback access.', + }), + ) + }, +} satisfies BuildAction< + typeof routes.accountSchedulePage.method, + typeof routes.accountSchedulePage.pattern +> + export const schedulePage = { middleware: [], async action() { diff --git a/extra/04.with-accounts/server/handlers/login-request.ts b/extra/04.with-accounts/server/handlers/login-request.ts new file mode 100644 index 0000000..29c9e65 --- /dev/null +++ b/extra/04.with-accounts/server/handlers/login-request.ts @@ -0,0 +1,85 @@ +import { type BuildAction } from 'remix/fetch-router' +import { getRequestIp, logAuditEvent } from '#server/audit-log.ts' +import { normalizeRedirectPath } from '#server/auth-redirect.ts' +import { createHostLoginRequest } from '#shared/host-account-store.ts' +import { type AppEnv } from '#types/env-schema.ts' +import { type routes } from '#server/routes.ts' +import { isRecordValue } from './schedule-handler-utils.ts' + +type LoginRequestBody = { + email?: unknown + redirectTo?: unknown +} + +export function createLoginRequestHandler(appEnv: Pick) { + return { + middleware: [], + async action({ request, url }) { + let body: LoginRequestBody + try { + const parsed = await request.json() + if (!isRecordValue(parsed)) { + return Response.json( + { ok: false, error: 'Invalid JSON payload.' }, + { status: 400 }, + ) + } + body = parsed as LoginRequestBody + } catch { + return Response.json( + { ok: false, error: 'Invalid JSON payload.' }, + { status: 400 }, + ) + } + + const email = typeof body.email === 'string' ? body.email : '' + const redirectTo = normalizeRedirectPath( + typeof body.redirectTo === 'string' ? body.redirectTo : null, + ) + const requestIp = getRequestIp(request) ?? undefined + + try { + const loginRequest = await createHostLoginRequest(appEnv.APP_DB, { + email, + }) + const loginUrl = new URL('/login/verify', url) + loginUrl.searchParams.set('token', loginRequest.loginToken) + loginUrl.searchParams.set('redirectTo', redirectTo) + + void logAuditEvent({ + category: 'auth', + action: 'login_link_requested', + result: 'success', + email: loginRequest.email, + ip: requestIp, + path: url.pathname, + }) + + return Response.json({ + ok: true, + email: loginRequest.email, + expiresAt: loginRequest.expiresAt, + loginLink: loginUrl.toString(), + }) + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Unable to create sign-in link.' + void logAuditEvent({ + category: 'auth', + action: 'login_link_requested', + result: 'failure', + email, + ip: requestIp, + path: url.pathname, + reason: message, + }) + return Response.json({ ok: false, error: message }, { status: 400 }) + } + }, + } satisfies BuildAction< + typeof routes.loginRequest.method, + typeof routes.loginRequest.pattern + > +} diff --git a/extra/04.with-accounts/server/handlers/login-verify.ts b/extra/04.with-accounts/server/handlers/login-verify.ts new file mode 100644 index 0000000..1319483 --- /dev/null +++ b/extra/04.with-accounts/server/handlers/login-verify.ts @@ -0,0 +1,61 @@ +import { type BuildAction } from 'remix/fetch-router' +import { getRequestIp, logAuditEvent } from '#server/audit-log.ts' +import { createAuthCookie } from '#server/auth-session.ts' +import { normalizeRedirectPath } from '#server/auth-redirect.ts' +import { consumeHostLoginToken } from '#shared/host-account-store.ts' +import { type AppEnv } from '#types/env-schema.ts' +import { type routes } from '#server/routes.ts' + +export function createLoginVerifyHandler(appEnv: Pick) { + return { + middleware: [], + async action({ request, url }) { + const loginToken = url.searchParams.get('token') ?? '' + const redirectTo = normalizeRedirectPath(url.searchParams.get('redirectTo')) + const requestIp = getRequestIp(request) ?? undefined + + const loginResult = await consumeHostLoginToken(appEnv.APP_DB, loginToken) + if (loginResult.status !== 'valid') { + const loginUrl = new URL('/login', url) + loginUrl.searchParams.set('redirectTo', redirectTo) + loginUrl.searchParams.set( + 'error', + loginResult.status === 'expired' ? 'expired-link' : 'invalid-link', + ) + void logAuditEvent({ + category: 'auth', + action: 'login_link_verified', + result: 'failure', + ip: requestIp, + path: url.pathname, + reason: loginResult.status, + }) + return Response.redirect(loginUrl, 302) + } + + const cookie = await createAuthCookie( + loginResult.session, + url.protocol === 'https:', + ) + void logAuditEvent({ + category: 'auth', + action: 'login_link_verified', + result: 'success', + email: loginResult.session.email, + ip: requestIp, + path: url.pathname, + }) + + return new Response(null, { + status: 302, + headers: { + Location: redirectTo, + 'Set-Cookie': cookie, + }, + }) + }, + } satisfies BuildAction< + typeof routes.loginVerify.method, + typeof routes.loginVerify.pattern + > +} diff --git a/extra/04.with-accounts/server/handlers/logout.ts b/extra/04.with-accounts/server/handlers/logout.ts new file mode 100644 index 0000000..e1d7767 --- /dev/null +++ b/extra/04.with-accounts/server/handlers/logout.ts @@ -0,0 +1,20 @@ +import { type BuildAction } from 'remix/fetch-router' +import { destroyAuthCookie, readAuthSession } from '#server/auth-session.ts' +import { normalizeRedirectPath } from '#server/auth-redirect.ts' +import { type routes } from '#server/routes.ts' + +export const logout = { + middleware: [], + async action({ request, url }) { + const session = await readAuthSession(request) + const redirectTo = normalizeRedirectPath(url.searchParams.get('redirectTo'), '/') + const cookie = await destroyAuthCookie(url.protocol === 'https:') + return new Response(null, { + status: 302, + headers: { + Location: session ? redirectTo : '/', + 'Set-Cookie': cookie, + }, + }) + }, +} satisfies BuildAction diff --git a/extra/04.with-accounts/server/handlers/schedule-claim.ts b/extra/04.with-accounts/server/handlers/schedule-claim.ts new file mode 100644 index 0000000..f566140 --- /dev/null +++ b/extra/04.with-accounts/server/handlers/schedule-claim.ts @@ -0,0 +1,99 @@ +import { type BuildAction } from 'remix/fetch-router' +import { readAuthSession } from '#server/auth-session.ts' +import { + claimScheduleOwnership, + getScheduleSnapshot, + verifyScheduleHostAccessToken, +} from '#shared/schedule-store.ts' +import { type AppEnv } from '#types/env-schema.ts' +import { type routes } from '#server/routes.ts' +import { getShareToken } from './schedule-handler-utils.ts' + +function isClaimValidationError(message: string) { + return /(not found|required|claimed)/i.test(message) +} + +export function createScheduleClaimHandler(appEnv: Pick) { + return { + middleware: [], + async action({ request, url }) { + const shareToken = getShareToken(url.pathname) + if (!shareToken) { + return Response.json( + { ok: false, error: 'Missing schedule token.' }, + { status: 400 }, + ) + } + + const session = await readAuthSession(request) + if (!session) { + return Response.json( + { ok: false, error: 'Sign in required.' }, + { status: 401 }, + ) + } + + const providedHostToken = request.headers.get('X-Host-Token')?.trim() + if (!providedHostToken) { + return Response.json( + { ok: false, error: 'Missing host access token.' }, + { status: 401 }, + ) + } + + const hostAccessVerification = await verifyScheduleHostAccessToken( + appEnv.APP_DB, + shareToken, + providedHostToken, + ) + if (hostAccessVerification === 'not-found') { + return Response.json( + { ok: false, error: 'Schedule not found.' }, + { status: 404 }, + ) + } + if (hostAccessVerification !== 'valid') { + return Response.json( + { ok: false, error: 'Invalid host access token.' }, + { status: 403 }, + ) + } + + try { + await claimScheduleOwnership(appEnv.APP_DB, { + shareToken, + ownerUserId: session.id, + }) + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unable to claim schedule.' + const status = /not found/i.test(message) + ? 404 + : isClaimValidationError(message) + ? 409 + : 500 + if (status === 500) { + console.error('schedule claim failed:', error) + } + return Response.json({ ok: false, error: message }, { status }) + } + + const snapshot = await getScheduleSnapshot(appEnv.APP_DB, shareToken) + if (!snapshot) { + return Response.json( + { ok: false, error: 'Schedule not found.' }, + { status: 404 }, + ) + } + + return Response.json({ + ok: true, + accountPath: `/account/schedules/${encodeURIComponent(shareToken)}`, + snapshot, + }) + }, + } satisfies BuildAction< + typeof routes.scheduleClaim.method, + typeof routes.scheduleClaim.pattern + > +} diff --git a/extra/04.with-accounts/server/handlers/schedule-host-auth.ts b/extra/04.with-accounts/server/handlers/schedule-host-auth.ts new file mode 100644 index 0000000..91314ad --- /dev/null +++ b/extra/04.with-accounts/server/handlers/schedule-host-auth.ts @@ -0,0 +1,102 @@ +import { readAuthSession } from '#server/auth-session.ts' +import { + verifyScheduleHostAccessToken, + verifyScheduleOwnerAccess, +} from '#shared/schedule-store.ts' + +type D1DatabaseLike = Pick['APP_DB'] + +type AuthorizedHostAccess = + | { + ok: true + accessMethod: 'host-link' + session: null + } + | { + ok: true + accessMethod: 'account' + session: NonNullable>> + } + | { + ok: false + response: Response + } + +export async function authorizeHostScheduleRequest(params: { + db: D1DatabaseLike + request: Request + shareToken: string +}) { + const providedHostToken = params.request.headers.get('X-Host-Token')?.trim() + if (providedHostToken) { + const hostAccessVerification = await verifyScheduleHostAccessToken( + params.db, + params.shareToken, + providedHostToken, + ) + if (hostAccessVerification === 'not-found') { + return { + ok: false, + response: Response.json( + { ok: false, error: 'Schedule not found.' }, + { status: 404 }, + ), + } satisfies AuthorizedHostAccess + } + if (hostAccessVerification !== 'valid') { + return { + ok: false, + response: Response.json( + { ok: false, error: 'Invalid host access token.' }, + { status: 403 }, + ), + } satisfies AuthorizedHostAccess + } + return { + ok: true, + accessMethod: 'host-link', + session: null, + } satisfies AuthorizedHostAccess + } + + const session = await readAuthSession(params.request) + if (!session) { + return { + ok: false, + response: Response.json( + { ok: false, error: 'Sign in required.' }, + { status: 401 }, + ), + } satisfies AuthorizedHostAccess + } + + const ownerAccess = await verifyScheduleOwnerAccess( + params.db, + params.shareToken, + session.id, + ) + if (ownerAccess === 'not-found') { + return { + ok: false, + response: Response.json( + { ok: false, error: 'Schedule not found.' }, + { status: 404 }, + ), + } satisfies AuthorizedHostAccess + } + if (ownerAccess !== 'valid') { + return { + ok: false, + response: Response.json( + { ok: false, error: 'You do not have access to this schedule.' }, + { status: 403 }, + ), + } satisfies AuthorizedHostAccess + } + + return { + ok: true, + accessMethod: 'account', + session, + } satisfies AuthorizedHostAccess +} diff --git a/extra/04.with-accounts/server/handlers/schedule-host-read.ts b/extra/04.with-accounts/server/handlers/schedule-host-read.ts index 02f61b3..1dd7452 100644 --- a/extra/04.with-accounts/server/handlers/schedule-host-read.ts +++ b/extra/04.with-accounts/server/handlers/schedule-host-read.ts @@ -1,11 +1,9 @@ import { type BuildAction } from 'remix/fetch-router' -import { - getScheduleSnapshot, - verifyScheduleHostAccessToken, -} from '#shared/schedule-store.ts' +import { getScheduleSnapshot } from '#shared/schedule-store.ts' import { type AppEnv } from '#types/env-schema.ts' import { type routes } from '#server/routes.ts' import { getShareToken } from './schedule-handler-utils.ts' +import { authorizeHostScheduleRequest } from './schedule-host-auth.ts' export function createScheduleHostReadHandler(appEnv: Pick) { return { @@ -19,31 +17,12 @@ export function createScheduleHostReadHandler(appEnv: Pick) { ) } - const providedHostToken = request.headers.get('X-Host-Token')?.trim() - if (!providedHostToken) { - return Response.json( - { ok: false, error: 'Missing host access token.' }, - { status: 401 }, - ) - } - - const hostAccessVerification = await verifyScheduleHostAccessToken( - appEnv.APP_DB, + const authorization = await authorizeHostScheduleRequest({ + db: appEnv.APP_DB, + request, shareToken, - providedHostToken, - ) - if (hostAccessVerification === 'not-found') { - return Response.json( - { ok: false, error: 'Schedule not found.' }, - { status: 404 }, - ) - } - if (hostAccessVerification !== 'valid') { - return Response.json( - { ok: false, error: 'Invalid host access token.' }, - { status: 403 }, - ) - } + }) + if (!authorization.ok) return authorization.response const snapshot = await getScheduleSnapshot(appEnv.APP_DB, shareToken) if (!snapshot) { diff --git a/extra/04.with-accounts/server/handlers/schedule-host-update.ts b/extra/04.with-accounts/server/handlers/schedule-host-update.ts index ce82883..5772840 100644 --- a/extra/04.with-accounts/server/handlers/schedule-host-update.ts +++ b/extra/04.with-accounts/server/handlers/schedule-host-update.ts @@ -2,11 +2,11 @@ import { type BuildAction } from 'remix/fetch-router' import { getScheduleSnapshot, updateScheduleHostSettings, - verifyScheduleHostAccessToken, } from '#shared/schedule-store.ts' import { type AppEnv } from '#types/env-schema.ts' import { type routes } from '#server/routes.ts' import { getShareToken, isRecordValue } from './schedule-handler-utils.ts' +import { authorizeHostScheduleRequest } from './schedule-host-auth.ts' type HostUpdateRequest = { hostName?: unknown @@ -66,30 +66,12 @@ export function createScheduleHostUpdateHandler( { status: 400 }, ) } - const providedHostToken = request.headers.get('X-Host-Token')?.trim() - if (!providedHostToken) { - return Response.json( - { ok: false, error: 'Missing host access token.' }, - { status: 401 }, - ) - } - const hostAccessVerification = await verifyScheduleHostAccessToken( - appEnv.APP_DB, + const authorization = await authorizeHostScheduleRequest({ + db: appEnv.APP_DB, + request, shareToken, - providedHostToken, - ) - if (hostAccessVerification === 'not-found') { - return Response.json( - { ok: false, error: 'Schedule not found.' }, - { status: 404 }, - ) - } - if (hostAccessVerification !== 'valid') { - return Response.json( - { ok: false, error: 'Invalid host access token.' }, - { status: 403 }, - ) - } + }) + if (!authorization.ok) return authorization.response let body: HostUpdateRequest try { diff --git a/extra/04.with-accounts/server/handlers/session.ts b/extra/04.with-accounts/server/handlers/session.ts new file mode 100644 index 0000000..3967234 --- /dev/null +++ b/extra/04.with-accounts/server/handlers/session.ts @@ -0,0 +1,15 @@ +import { type BuildAction } from 'remix/fetch-router' +import { readAuthSession } from '#server/auth-session.ts' +import { type routes } from '#server/routes.ts' + +export const session = { + middleware: [], + async action({ request }) { + const authSession = await readAuthSession(request) + return Response.json({ + ok: true, + authenticated: authSession !== null, + session: authSession, + }) + }, +} satisfies BuildAction diff --git a/extra/04.with-accounts/server/normalize-email.ts b/extra/04.with-accounts/server/normalize-email.ts new file mode 100644 index 0000000..907a603 --- /dev/null +++ b/extra/04.with-accounts/server/normalize-email.ts @@ -0,0 +1,3 @@ +export function normalizeEmail(email: string) { + return email.trim().toLowerCase() +} diff --git a/extra/04.with-accounts/server/router.ts b/extra/04.with-accounts/server/router.ts index 9b0f3b7..8bacd3d 100644 --- a/extra/04.with-accounts/server/router.ts +++ b/extra/04.with-accounts/server/router.ts @@ -1,16 +1,28 @@ import { createRouter } from 'remix/fetch-router' import { type AppEnv } from '#types/env-schema.ts' +import { createAccountSchedulesReadHandler } from './handlers/account-schedules-read.ts' import { robotsTxt, sitemapXml } from './handlers/seo-assets.ts' import { createHealthHandler } from './handlers/health.ts' +import { createLoginRequestHandler } from './handlers/login-request.ts' +import { createLoginVerifyHandler } from './handlers/login-verify.ts' import { createScheduleHostReadHandler } from './handlers/schedule-host-read.ts' -import { scheduleHostPage, schedulePage } from './handlers/app-pages.ts' +import { + accountSchedulePage, + accountSchedulesPage, + loginPage, + scheduleHostPage, + schedulePage, +} from './handlers/app-pages.ts' import { home } from './handlers/home.ts' +import { logout } from './handlers/logout.ts' import { createScheduleCreateHandler } from './handlers/schedule-create.ts' +import { createScheduleClaimHandler } from './handlers/schedule-claim.ts' import { createScheduleDeleteSubmissionHandler } from './handlers/schedule-delete-submission.ts' import { createScheduleHostUpdateHandler } from './handlers/schedule-host-update.ts' import { createScheduleRenameSubmissionHandler } from './handlers/schedule-rename-submission.ts' import { createScheduleReadHandler } from './handlers/schedule-read.ts' import { createScheduleSubmitAvailabilityHandler } from './handlers/schedule-submit-availability.ts' +import { session } from './handlers/session.ts' import { blogIndex, blogPost, @@ -32,6 +44,10 @@ export function createAppRouter(appEnv: AppEnv) { }) router.map(routes.home, home) + router.map(routes.loginPage, loginPage) + router.map(routes.loginVerify, createLoginVerifyHandler(appEnv)) + router.map(routes.accountSchedulesPage, accountSchedulesPage) + router.map(routes.accountSchedulePage, accountSchedulePage) router.map(routes.schedulePage, schedulePage) router.map(routes.scheduleHostPage, scheduleHostPage) router.map(routes.howItWorks, howItWorks) @@ -43,9 +59,17 @@ export function createAppRouter(appEnv: AppEnv) { router.map(routes.robotsTxt, robotsTxt) router.map(routes.sitemapXml, sitemapXml) router.map(routes.health, createHealthHandler(appEnv)) + router.map(routes.session, session) + router.map(routes.loginRequest, createLoginRequestHandler(appEnv)) + router.map(routes.logout, logout) + router.map( + routes.accountSchedulesRead, + createAccountSchedulesReadHandler(appEnv), + ) router.map(routes.scheduleCreate, createScheduleCreateHandler(appEnv)) router.map(routes.scheduleRead, createScheduleReadHandler(appEnv)) router.map(routes.scheduleHostRead, createScheduleHostReadHandler(appEnv)) + router.map(routes.scheduleClaim, createScheduleClaimHandler(appEnv)) router.map( routes.scheduleSubmitAvailability, createScheduleSubmitAvailabilityHandler(appEnv), diff --git a/extra/04.with-accounts/server/routes.ts b/extra/04.with-accounts/server/routes.ts index 814fe83..0058774 100644 --- a/extra/04.with-accounts/server/routes.ts +++ b/extra/04.with-accounts/server/routes.ts @@ -2,6 +2,10 @@ import { post, route } from 'remix/fetch-router/routes' export const routes = route({ home: '/', + loginPage: '/login', + loginVerify: '/login/verify', + accountSchedulesPage: '/account/schedules', + accountSchedulePage: '/account/schedules/:shareToken', schedulePage: '/s/:shareToken', scheduleHostPage: '/s/:shareToken/:hostAccessToken', howItWorks: '/how-it-works', @@ -13,9 +17,14 @@ export const routes = route({ robotsTxt: '/robots.txt', sitemapXml: '/sitemap.xml', health: '/health', + session: '/api/session', + loginRequest: post('/api/login'), + logout: post('/logout'), + accountSchedulesRead: '/api/account/schedules', scheduleCreate: post('/api/schedules'), scheduleRead: '/api/schedules/:shareToken', scheduleHostRead: '/api/schedules/:shareToken/host-snapshot', + scheduleClaim: post('/api/schedules/:shareToken/claim'), scheduleSubmitAvailability: post('/api/schedules/:shareToken/availability'), scheduleDeleteSubmission: post( '/api/schedules/:shareToken/submission-delete', diff --git a/extra/04.with-accounts/shared/host-account-store.ts b/extra/04.with-accounts/shared/host-account-store.ts new file mode 100644 index 0000000..33c4e90 --- /dev/null +++ b/extra/04.with-accounts/shared/host-account-store.ts @@ -0,0 +1,210 @@ +type PreparedStatementLike = { + bind(...values: Array): PreparedStatementLike + run(): Promise + first(): Promise + all(): Promise<{ results: Array }> +} + +type D1DatabaseLike = { + prepare(query: string): PreparedStatementLike +} + +type UserRow = { + id: string + email: string +} + +type HostLoginTokenRow = { + id: string + user_id: string + email: string + expires_at: string +} + +export type HostScheduleSummary = { + shareToken: string + title: string + createdAt: string + claimedAt: string | null +} + +const hostLoginTokenLifetimeMs = 15 * 60_000 +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function toHex(bytes: Uint8Array) { + return Array.from(bytes) + .map((value) => value.toString(16).padStart(2, '0')) + .join('') +} + +function normalizeEmail(email: string) { + return email.trim().toLowerCase() +} + +function isValidEmailAddress(value: string) { + return emailPattern.test(value) +} + +export function createHostLoginToken() { + return `${crypto.randomUUID().replace(/-/g, '')}${crypto.randomUUID().replace(/-/g, '')}` +} + +export async function hashHostLoginToken(token: string) { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(token), + ) + return toHex(new Uint8Array(digest)) +} + +export async function ensureHostUser(db: D1DatabaseLike, email: string) { + const normalizedEmail = normalizeEmail(email) + if (!normalizedEmail || !isValidEmailAddress(normalizedEmail)) { + throw new Error('A valid email address is required.') + } + + const existingUser = await db + .prepare( + `SELECT + id, + email + FROM users + WHERE email = ?1 + LIMIT 1`, + ) + .bind(normalizedEmail) + .first() + if (existingUser) { + return existingUser + } + + const userId = crypto.randomUUID() + await db + .prepare( + `INSERT INTO users ( + id, + email, + created_at, + updated_at + ) VALUES (?1, ?2, ?3, ?3)`, + ) + .bind(userId, normalizedEmail, new Date().toISOString()) + .run() + + return { + id: userId, + email: normalizedEmail, + } +} + +export async function createHostLoginRequest( + db: D1DatabaseLike, + input: { email: string }, +) { + const user = await ensureHostUser(db, input.email) + const loginToken = createHostLoginToken() + const loginTokenHash = await hashHostLoginToken(loginToken) + const now = new Date() + const expiresAt = new Date(now.getTime() + hostLoginTokenLifetimeMs).toISOString() + + await db + .prepare( + `INSERT INTO host_login_tokens ( + id, + user_id, + token_hash, + expires_at, + created_at + ) VALUES (?1, ?2, ?3, ?4, ?5)`, + ) + .bind(crypto.randomUUID(), user.id, loginTokenHash, expiresAt, now.toISOString()) + .run() + + return { + email: user.email, + loginToken, + expiresAt, + } +} + +export async function consumeHostLoginToken( + db: D1DatabaseLike, + providedLoginToken: string, +) { + const normalizedLoginToken = providedLoginToken.trim() + if (!normalizedLoginToken) { + return { status: 'invalid' as const } + } + + const loginTokenHash = await hashHostLoginToken(normalizedLoginToken) + const tokenRow = await db + .prepare( + `SELECT + host_login_tokens.id, + host_login_tokens.user_id, + users.email, + host_login_tokens.expires_at + FROM host_login_tokens + INNER JOIN users ON users.id = host_login_tokens.user_id + WHERE host_login_tokens.token_hash = ?1 + LIMIT 1`, + ) + .bind(loginTokenHash) + .first() + + if (!tokenRow) { + return { status: 'invalid' as const } + } + + await db + .prepare( + `DELETE FROM host_login_tokens + WHERE id = ?1`, + ) + .bind(tokenRow.id) + .run() + + const expiresAtMs = Date.parse(tokenRow.expires_at) + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) { + return { status: 'expired' as const } + } + + return { + status: 'valid' as const, + session: { + id: tokenRow.user_id, + email: tokenRow.email, + }, + } +} + +export async function listSchedulesForHostUser( + db: D1DatabaseLike, + userId: string, +) { + const rows = await db + .prepare( + `SELECT + share_token, + title, + created_at, + claimed_at + FROM schedules + WHERE owner_user_id = ?1 + ORDER BY COALESCE(claimed_at, created_at) DESC, created_at DESC`, + ) + .bind(userId) + .all<{ + share_token: string + title: string + created_at: string + claimed_at: string | null + }>() + + return rows.results.map((row) => ({ + shareToken: row.share_token, + title: row.title, + createdAt: row.created_at, + claimedAt: row.claimed_at ?? null, + })) satisfies Array +} diff --git a/extra/04.with-accounts/shared/schedule-store.ts b/extra/04.with-accounts/shared/schedule-store.ts index ce58b1e..ddbca18 100644 --- a/extra/04.with-accounts/shared/schedule-store.ts +++ b/extra/04.with-accounts/shared/schedule-store.ts @@ -8,6 +8,8 @@ export type ScheduleRecord = { rangeStartUtc: string rangeEndUtc: string createdAt: string + ownerUserId: string | null + claimedAt: string | null } export type AttendeeRecord = { @@ -93,6 +95,8 @@ type ScheduleRow = { range_start_utc: string range_end_utc: string created_at: string + owner_user_id: string | null + claimed_at: string | null } type AttendeeRow = { @@ -121,6 +125,13 @@ type ScheduleHostAccessTokenRow = { host_access_token_hash: string | null } +export type HostScheduleSummary = { + shareToken: string + title: string + createdAt: string + claimedAt: string | null +} + export function createShareToken() { return crypto.randomUUID().replace(/-/g, '').slice(0, 16) } @@ -232,6 +243,8 @@ function toScheduleRecord(row: ScheduleRow): ScheduleRecord { rangeStartUtc: row.range_start_utc, rangeEndUtc: row.range_end_utc, createdAt: row.created_at, + ownerUserId: row.owner_user_id ?? null, + claimedAt: row.claimed_at ?? null, } } @@ -331,7 +344,9 @@ export async function getScheduleByShareToken( interval_minutes, range_start_utc, range_end_utc, - created_at + created_at, + owner_user_id, + claimed_at FROM schedules WHERE share_token = ?1 LIMIT 1`, @@ -366,6 +381,84 @@ export async function verifyScheduleHostAccessToken( return row.host_access_token_hash === providedTokenHash ? 'valid' : 'invalid' } +export async function verifyScheduleOwnerAccess( + db: D1DatabaseLike, + shareToken: string, + ownerUserId: string, +) { + const schedule = await getScheduleByShareToken(db, shareToken) + if (!schedule) return 'not-found' as const + return schedule.ownerUserId === ownerUserId ? ('valid' as const) : ('invalid' as const) +} + +export async function claimScheduleOwnership( + db: D1DatabaseLike, + input: { shareToken: string; ownerUserId: string }, +) { + const schedule = await getScheduleByShareToken(db, input.shareToken) + if (!schedule) { + throw new Error('Schedule not found.') + } + if (schedule.ownerUserId && schedule.ownerUserId !== input.ownerUserId) { + throw new Error('Schedule already claimed by another account.') + } + if (schedule.ownerUserId === input.ownerUserId) { + return { + scheduleId: schedule.id, + claimed: false, + claimedAt: schedule.claimedAt, + } + } + + const claimedAt = new Date().toISOString() + await db + .prepare( + `UPDATE schedules + SET owner_user_id = ?2, + claimed_at = ?3 + WHERE id = ?1`, + ) + .bind(schedule.id, input.ownerUserId, claimedAt) + .run() + + return { + scheduleId: schedule.id, + claimed: true, + claimedAt, + } +} + +export async function listSchedulesOwnedByUser( + db: D1DatabaseLike, + ownerUserId: string, +) { + const rows = await db + .prepare( + `SELECT + share_token, + title, + created_at, + claimed_at + FROM schedules + WHERE owner_user_id = ?1 + ORDER BY COALESCE(claimed_at, created_at) DESC, created_at DESC`, + ) + .bind(ownerUserId) + .all<{ + share_token: string + title: string + created_at: string + claimed_at: string | null + }>() + + return rows.results.map((row) => ({ + shareToken: row.share_token, + title: row.title, + createdAt: row.created_at, + claimedAt: row.claimed_at ?? null, + })) satisfies Array +} + export async function createSchedule( db: D1DatabaseLike, input: ScheduleInsertInput, diff --git a/extra/04.with-accounts/tools/prepare-e2e-env.ts b/extra/04.with-accounts/tools/prepare-e2e-env.ts index d738133..8d1693a 100644 --- a/extra/04.with-accounts/tools/prepare-e2e-env.ts +++ b/extra/04.with-accounts/tools/prepare-e2e-env.ts @@ -1,18 +1,32 @@ -import { copyFileSync, existsSync } from 'node:fs' +import { + appendFileSync, + copyFileSync, + existsSync, + readFileSync, +} from 'node:fs' import { join } from 'node:path' const envPath = join(process.cwd(), '.env') -if (existsSync(envPath)) { - process.exit(0) -} - const examplePath = join(process.cwd(), '.env.example') -if (!existsSync(examplePath)) { + +if (!existsSync(envPath) && !existsSync(examplePath)) { console.error( 'Missing .env and .env.example; cannot prepare E2E environment.', ) process.exit(1) } -copyFileSync(examplePath, envPath) -console.log('Created .env from .env.example for E2E tests.') +if (!existsSync(envPath)) { + copyFileSync(examplePath, envPath) + console.log('Created .env from .env.example for E2E tests.') +} + +const envContents = readFileSync(envPath, 'utf8') +if (!/^COOKIE_SECRET=/m.test(envContents)) { + const fallbackSecret = 'e2e-cookie-secret' + appendFileSync( + envPath, + `${envContents.endsWith('\n') || envContents.length === 0 ? '' : '\n'}COOKIE_SECRET=${fallbackSecret}\n`, + ) + console.log('Added COOKIE_SECRET to .env for E2E tests.') +} diff --git a/extra/04.with-accounts/types/env-schema.ts b/extra/04.with-accounts/types/env-schema.ts index c85264d..9e717ff 100644 --- a/extra/04.with-accounts/types/env-schema.ts +++ b/extra/04.with-accounts/types/env-schema.ts @@ -55,11 +55,19 @@ const optionalCommitShaSchema = createSchema( }, ) +const requiredSecretStringSchema = createSchema((value, context) => { + if (typeof value !== 'string') return fail('Expected string', context.path) + const trimmed = value.trim() + if (!trimmed) return fail('Expected non-empty string', context.path) + return { value: trimmed } +}) + export const EnvSchema = object({ APP_DB: d1DatabaseSchema, SCHEDULE_ROOM: durableObjectNamespaceSchema, APP_BASE_URL: optionalUrlStringSchema, APP_COMMIT_SHA: optionalCommitShaSchema, + COOKIE_SECRET: requiredSecretStringSchema, }) export type AppEnv = InferOutput