feat: require owner login and add onboarding

This commit is contained in:
2026-09-28 19:14:14 +09:00
parent 57882cf5f9
commit 85d23a328b
42 changed files with 2400 additions and 23 deletions
+5 -2
View File
@@ -1,4 +1,4 @@
import { Link, useLocation } from '@tanstack/react-router'
import { Link, useLocation, useRouteContext } from '@tanstack/react-router'
import {
Activity,
House,
@@ -35,6 +35,7 @@ export function WorkspaceNavigation({
footer,
hasTemporaryDecks,
}: WorkspaceNavigationProps) {
const { auth } = useRouteContext({ from: '__root__' })
const [managingAccounts, setManagingAccounts] = useState(false)
const pathname = useLocation({ select: (location) => location.pathname })
const navigation = [
@@ -129,7 +130,9 @@ export function WorkspaceNavigation({
onClick={() => setManagingAccounts(true)}
>
<Avatar className="size-8">
<AvatarFallback>Y</AvatarFallback>
<AvatarFallback>
{auth.owner?.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
</SidebarMenuButton>
</SidebarMenuItem>
+34
View File
@@ -0,0 +1,34 @@
export type SafeOwner = {
id: number
email: string
name: string
onboardingCompletedAt: number | null
}
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null }
type AuthInput =
| { action: 'login'; email: string; password: string }
| {
action: 'setup'
email: string
password: string
name: string
setupToken: string
}
| { action: 'logout' }
| { action: 'onboard'; name: string }
export async function authRequest(input: AuthInput): Promise<AuthState> {
const response = await fetch('/api/auth', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Something went wrong. Please try again.')
}
return result
}
+199
View File
@@ -0,0 +1,199 @@
import { ArrowRight, LockKeyhole } from 'lucide-react'
import { type FormEvent, type ReactNode, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { authRequest } from './auth-client'
import './auth.css'
export function AuthFrame({ children }: { children: ReactNode }) {
return (
<main className="auth-page">
<div className="auth-panel">
<div className="auth-brand">
<LockKeyhole size={16} aria-hidden="true" />
Personal Workspace
</div>
{children}
</div>
</main>
)
}
export function LoginPage() {
return <CredentialsForm setup={false} />
}
export function SetupPage() {
return <CredentialsForm setup />
}
function CredentialsForm({ setup }: { setup: boolean }) {
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (pending) return
const data = new FormData(event.currentTarget)
const email = String(data.get('email') ?? '').trim()
const password = String(data.get('password') ?? '')
if (setup && password !== data.get('confirmPassword')) {
setError('Passwords do not match.')
return
}
setPending(true)
setError('')
try {
const state = await authRequest(
setup
? {
action: 'setup',
email,
password,
name: String(data.get('name') ?? '').trim(),
setupToken: String(data.get('setupToken') ?? '').trim(),
}
: { action: 'login', email, password },
)
if (!state.owner?.onboardingCompletedAt) {
window.location.assign('/onboarding')
return
}
const returnTo = new URLSearchParams(window.location.search).get(
'returnTo',
)
const destination =
returnTo &&
['/', '/deck', '/support', '/journal', '/inbox', '/vitals'].includes(
returnTo,
)
? returnTo
: '/'
window.location.assign(destination)
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to sign in. Please try again.',
)
setPending(false)
}
}
return (
<AuthFrame>
<header className="auth-heading">
<h1>{setup ? 'Make yourself at home.' : 'Welcome back.'}</h1>
<p>
{setup
? 'Create the owner account for your personal workspace.'
: 'Sign in to your personal workspace.'}
</p>
</header>
<form onSubmit={submit} className="auth-form" aria-busy={pending}>
{setup && (
<div className="auth-field">
<Label htmlFor="name">Your name</Label>
<Input
id="name"
name="name"
autoComplete="given-name"
required
maxLength={80}
disabled={pending}
/>
</div>
)}
<div className="auth-field">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="username"
required
maxLength={254}
disabled={pending}
/>
</div>
<div className="auth-field">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete={setup ? 'new-password' : 'current-password'}
required
minLength={setup ? 15 : undefined}
maxLength={128}
aria-describedby={setup ? 'password-hint' : undefined}
disabled={pending}
/>
{setup && (
<p id="password-hint" className="auth-hint">
Use 15–128 characters. A few memorable words work well.
</p>
)}
</div>
{setup && (
<>
<div className="auth-field">
<Label htmlFor="confirmPassword">Confirm password</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
minLength={15}
maxLength={128}
disabled={pending}
/>
</div>
<div className="auth-field">
<Label htmlFor="setupToken">Setup code</Label>
<Input
id="setupToken"
name="setupToken"
type="password"
autoComplete="off"
required
aria-describedby="setup-hint"
disabled={pending}
/>
<p id="setup-hint" className="auth-hint">
Use the setup code provided by your server administrator.
</p>
</div>
</>
)}
{error && (
<p className="auth-error" role="alert">
{error}
</p>
)}
<Button
type="submit"
size="lg"
disabled={pending}
className="auth-submit"
>
{pending
? setup
? 'Creating account…'
: 'Signing in…'
: setup
? 'Create account'
: 'Sign in'}
<ArrowRight aria-hidden="true" />
</Button>
</form>
<p className="auth-footnote">
{setup
? 'This workspace has one owner. Connections can be added later.'
: 'A private workspace. Access is limited to its owner.'}
</p>
</AuthFrame>
)
}
+136
View File
@@ -0,0 +1,136 @@
.auth-page {
min-height: 100svh;
display: grid;
place-items: center;
padding: 48px 24px;
background: var(--background);
color: var(--foreground);
}
.auth-panel {
width: 100%;
max-width: 390px;
min-width: 0;
}
.auth-brand {
display: flex;
align-items: center;
gap: 9px;
color: var(--muted-foreground);
font-size: 13px;
margin-bottom: 44px;
}
.auth-heading {
margin-bottom: 28px;
}
.auth-heading h1 {
font-size: 27px;
font-weight: 550;
letter-spacing: -0.8px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.auth-heading p {
color: var(--muted-foreground);
font-size: 14px;
line-height: 1.6;
margin-top: 12px;
}
.auth-form {
display: grid;
gap: 20px;
}
.auth-field {
display: grid;
gap: 9px;
}
.auth-field input {
height: 40px;
}
.auth-hint,
.auth-footnote,
.auth-prototype {
font-size: 12px;
line-height: 1.6;
color: var(--muted-foreground);
}
.auth-submit {
width: 100%;
margin-top: 4px;
justify-content: space-between;
padding-inline: 14px;
}
.auth-footnote {
margin-top: 24px;
}
.auth-error {
color: var(--destructive);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.auth-step {
color: var(--muted-foreground);
font-size: 12px;
margin-bottom: 16px;
}
.auth-overview {
list-style: none;
padding: 0;
margin: 0 0 28px;
display: grid;
gap: 24px;
}
.auth-overview li {
display: flex;
gap: 14px;
align-items: flex-start;
}
.auth-overview svg {
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 2px;
color: var(--muted-foreground);
}
.auth-overview strong {
font-size: 14px;
font-weight: 500;
}
.auth-overview p {
font-size: 13px;
line-height: 1.6;
color: var(--muted-foreground);
margin-top: 4px;
}
.auth-prototype {
padding-top: 20px;
border-top: 1px solid var(--border);
}
.auth-actions {
margin-top: 26px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.auth-footer {
margin-top: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted-foreground);
font-size: 12px;
}
.auth-footer span {
min-width: 0;
overflow-wrap: anywhere;
}
@media (max-width: 480px) {
.auth-page {
padding: 32px 24px;
}
.auth-brand {
margin-bottom: 36px;
}
}
+241
View File
@@ -0,0 +1,241 @@
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute } from 'node:path'
import { eq, lte } from 'drizzle-orm'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema'
import type { SafeOwner } from './auth-client'
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
const derive = (password: string, salt: string) =>
new Promise<Buffer>((resolve, reject) =>
scrypt(password, salt, 64, scryptOptions, (error, key) =>
error ? reject(error) : resolve(key),
),
)
const scryptOptions = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }
const throttleWindow = 15 * 60_000
export class AuthError extends Error {
constructor(
public status: number,
message: string,
) {
super(message)
this.name = 'AuthError'
}
}
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
const equal = (left: string, right: string) =>
timingSafeEqual(Buffer.from(hash(left)), Buffer.from(hash(right)))
function safeOwner(owner: typeof workspaceOwner.$inferSelect): SafeOwner {
return {
id: owner.id,
email: owner.email,
name: owner.name,
onboardingCompletedAt: owner.onboardingCompletedAt,
}
}
export function readAuthState(database = getDatabase()) {
return {
needsSetup: !database
.select({ id: workspaceOwner.id })
.from(workspaceOwner)
.get(),
}
}
/** Bootstrap secret is read only by the server or operator CLI, never sent to clients. */
export function getSetupToken() {
const configured = process.env.WORKSPACE_SETUP_TOKEN
if (configured) {
if (configured.length < 32)
throw new AuthError(
503,
'The setup token must contain at least 32 characters.',
)
return configured
}
const dbPath = process.env.TWITTER_LITE_DB_PATH
if (!dbPath || !isAbsolute(dbPath))
throw new AuthError(503, 'Configure a workspace database or setup token.')
const path = `${dbPath}.setup-token`
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
try {
writeFileSync(path, randomBytes(32).toString('base64url'), {
mode: 0o600,
flag: 'wx',
})
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
}
const token = readFileSync(path, 'utf8').trim()
if (token.length < 32)
throw new AuthError(503, 'The workspace setup token is invalid.')
return token
}
function consumeAttempt(database: AppDatabase) {
database.transaction(
(tx) => {
const now = Date.now()
const current = tx.select().from(authThrottle).get()
if (current && current.resetAt > now) {
if (current.attempts >= 10)
throw new AuthError(
429,
'Too many attempts. Please try again in 15 minutes.',
)
tx.update(authThrottle)
.set({ attempts: current.attempts + 1 })
.where(eq(authThrottle.id, 1))
.run()
} else {
tx.insert(authThrottle)
.values({ id: 1, attempts: 1, resetAt: now + throttleWindow })
.onConflictDoUpdate({
target: authThrottle.id,
set: { attempts: 1, resetAt: now + throttleWindow },
})
.run()
}
},
{ behavior: 'immediate' },
)
}
function session(
database: Pick<AppDatabase, 'insert' | 'delete'>,
owner: typeof workspaceOwner.$inferSelect,
) {
const sessionToken = randomBytes(32).toString('base64url')
database
.delete(authSessions)
.where(lte(authSessions.expiresAt, Date.now()))
.run()
database
.insert(authSessions)
.values({
tokenHash: hash(sessionToken),
ownerId: owner.id,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
})
.run()
database.delete(authThrottle).where(eq(authThrottle.id, 1)).run()
return { sessionToken, owner: safeOwner(owner) }
}
function cleanName(name: string) {
const cleaned = name.trim()
if (!cleaned || cleaned.length > 80)
throw new AuthError(400, 'Enter a name of up to 80 characters.')
return cleaned
}
export async function createOwner(
input: { email: string; password: string; name: string; setupToken: string },
database = getDatabase(),
) {
if (!readAuthState(database).needsSetup)
throw new AuthError(
409,
'This workspace is already set up. Please sign in.',
)
consumeAttempt(database)
if (!equal(input.setupToken, getSetupToken()))
throw new AuthError(401, 'Invalid setup credentials.')
const email = input.email.trim().toLowerCase()
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
throw new AuthError(400, 'Enter a valid email address.')
if (input.password.length < 15 || input.password.length > 128)
throw new AuthError(400, 'Use a password between 15 and 128 characters.')
const name = cleanName(input.name)
const salt = randomBytes(16).toString('hex')
const derived = (await derive(input.password, salt)) as Buffer
return database.transaction(
(tx) => {
if (tx.select().from(workspaceOwner).get())
throw new AuthError(
409,
'This workspace is already set up. Please sign in.',
)
const owner = {
id: 1,
email,
name,
passwordHash: `${salt}:${derived.toString('hex')}`,
onboardingCompletedAt: null,
createdAt: Date.now(),
}
tx.insert(workspaceOwner).values(owner).run()
return session(tx, owner)
},
{ behavior: 'immediate' },
)
}
export async function signIn(
input: { email: string; password: string },
database = getDatabase(),
) {
consumeAttempt(database)
const owner = database.select().from(workspaceOwner).get()
if (input.password.length > 128 || input.email.length > 254)
throw new AuthError(401, 'Invalid email or password.')
const [salt = '', expected = ''] = owner?.passwordHash.split(':') ?? [
'0'.repeat(32),
'0'.repeat(128),
]
const actual = (await derive(input.password, salt)) as Buffer
if (
!owner ||
!equal(input.email.trim().toLowerCase(), owner.email) ||
!timingSafeEqual(actual, Buffer.from(expected, 'hex'))
)
throw new AuthError(401, 'Invalid email or password.')
return session(database, owner)
}
export function getSession(
token: string | undefined,
database = getDatabase(),
): SafeOwner | null {
if (!token || token.length > 128) return null
const found = database
.select()
.from(authSessions)
.where(eq(authSessions.tokenHash, hash(token)))
.get()
if (!found) return null
if (found.expiresAt <= Date.now()) {
database
.delete(authSessions)
.where(eq(authSessions.tokenHash, found.tokenHash))
.run()
return null
}
const owner = database
.select()
.from(workspaceOwner)
.where(eq(workspaceOwner.id, found.ownerId))
.get()
return owner ? safeOwner(owner) : null
}
export function signOut(token: string | undefined, database = getDatabase()) {
if (token)
database
.delete(authSessions)
.where(eq(authSessions.tokenHash, hash(token)))
.run()
}
export function completeOnboarding(
token: string,
input: { name: string },
database = getDatabase(),
): SafeOwner {
const owner = getSession(token, database)
if (!owner) throw new AuthError(401, 'Please sign in.')
const update = {
name: cleanName(input.name),
onboardingCompletedAt: owner.onboardingCompletedAt ?? Date.now(),
}
database
.update(workspaceOwner)
.set(update)
.where(eq(workspaceOwner.id, owner.id))
.run()
return { ...owner, ...update }
}
+150
View File
@@ -0,0 +1,150 @@
// @vitest-environment node
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { openDatabase } from '../storage/database.server'
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema'
import {
completeOnboarding,
createOwner,
getSession,
getSetupToken,
readAuthState,
SESSION_MAX_AGE_SECONDS,
signIn,
signOut,
} from './auth.server'
let database: ReturnType<typeof openDatabase>
const credentials = {
email: '[email protected]',
password: 'correct horse battery staple',
name: 'Owner',
setupToken: 's'.repeat(32),
}
beforeEach(() => {
database = openDatabase(':memory:')
vi.stubEnv('WORKSPACE_SETUP_TOKEN', credentials.setupToken)
})
afterEach(() => {
database.$client.close()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
describe('owner authentication', () => {
it('requires a private setup token and stores only derived credentials and session tokens', async () => {
expect(readAuthState(database)).toEqual({ needsSetup: true })
await expect(
createOwner({ ...credentials, setupToken: 'wrong' }, database),
).rejects.toMatchObject({ status: 401 })
expect(readAuthState(database).needsSetup).toBe(true)
const result = await createOwner(credentials, database)
expect(readAuthState(database)).toEqual({ needsSetup: false })
expect(result.owner.email).toBe(credentials.email)
expect(result.owner).not.toHaveProperty('passwordHash')
expect(
database.select().from(workspaceOwner).get()?.passwordHash,
).not.toContain(credentials.password)
expect(database.select().from(authSessions).get()?.tokenHash).not.toEqual(
result.sessionToken,
)
expect(getSession(result.sessionToken, database)).toEqual(result.owner)
await expect(createOwner(credentials, database)).rejects.toMatchObject({
status: 409,
})
})
it('only allows one owner even when setup requests race', async () => {
const results = await Promise.allSettled([
createOwner(credentials, database),
createOwner(credentials, database),
])
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1)
expect(database.select().from(workspaceOwner).all()).toHaveLength(1)
})
it('checks the email and password and revokes logged-out sessions', async () => {
await createOwner(credentials, database)
await expect(
signIn({ ...credentials, password: 'wrong' }, database),
).rejects.toMatchObject({
status: 401,
message: 'Invalid email or password.',
})
await expect(
signIn({ ...credentials, email: '[email protected]' }, database),
).rejects.toMatchObject({
status: 401,
message: 'Invalid email or password.',
})
const result = await signIn(
{ ...credentials, email: '[email protected]' },
database,
)
expect(database.select().from(authThrottle).all()).toHaveLength(0)
expect(getSession('invented', database)).toBeNull()
expect(getSession(result.sessionToken, database)).not.toBeNull()
signOut(result.sessionToken, database)
expect(getSession(result.sessionToken, database)).toBeNull()
})
it('expires sessions and stores onboarding completion', async () => {
const result = await createOwner(credentials, database)
expect(result.owner.onboardingCompletedAt).toBeNull()
const owner = completeOnboarding(
result.sessionToken,
{ name: 'Yuta' },
database,
)
expect(owner.name).toBe('Yuta')
expect(owner.onboardingCompletedAt).toBeTypeOf('number')
expect(getSession(result.sessionToken, database)).toEqual(owner)
expect(() =>
completeOnboarding('invalid', { name: 'Other' }, database),
).toThrow('Please sign in.')
vi.spyOn(Date, 'now').mockReturnValue(
Date.now() + SESSION_MAX_AGE_SECONDS * 1000 + 1,
)
expect(getSession(result.sessionToken, database)).toBeNull()
expect(database.select().from(authSessions).all()).toHaveLength(0)
})
it('bounds setup and login attempts persistently and permits retry after cooldown', async () => {
for (let index = 0; index < 10; index++)
await expect(
createOwner({ ...credentials, setupToken: 'wrong' }, database),
).rejects.toMatchObject({ status: 401 })
await expect(signIn(credentials, database)).rejects.toMatchObject({
status: 429,
})
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 15 * 60_000 + 1)
await expect(createOwner(credentials, database)).resolves.toHaveProperty(
'sessionToken',
)
})
it('rejects short passwords and invalid identity fields', async () => {
await expect(
createOwner({ ...credentials, password: 'short' }, database),
).rejects.toMatchObject({ status: 400 })
await expect(
createOwner({ ...credentials, email: 'invalid' }, database),
).rejects.toMatchObject({ status: 400 })
await expect(
createOwner({ ...credentials, name: ' ' }, database),
).rejects.toMatchObject({ status: 400 })
})
it('creates a stable local setup token with private file permissions', () => {
const directory = mkdtempSync(join(tmpdir(), 'workspace-setup-'))
try {
vi.stubEnv('WORKSPACE_SETUP_TOKEN', '')
const path = join(directory, 'workspace.sqlite')
vi.stubEnv('TWITTER_LITE_DB_PATH', path)
const token = getSetupToken()
expect(token.length).toBeGreaterThanOrEqual(32)
expect(getSetupToken()).toBe(token)
expect(readFileSync(`${path}.setup-token`, 'utf8')).toBe(token)
expect(statSync(`${path}.setup-token`).mode & 0o777).toBe(0o600)
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
})
+36
View File
@@ -0,0 +1,36 @@
import { getSession, readAuthState } from './auth.server'
import { sessionToken } from './http.server'
const publicPaths = new Set(['/login', '/setup', '/api/auth'])
export function checkSessionAccess(request: Request): Response | null {
const url = new URL(request.url)
if (publicPaths.has(url.pathname)) return null
const owner = getSession(sessionToken(request))
if (owner?.onboardingCompletedAt) return null
if (owner && url.pathname === '/onboarding') return null
const document =
request.method === 'GET' &&
request.headers.get('accept')?.includes('text/html') &&
!url.pathname.startsWith('/api/') &&
!url.pathname.startsWith('/_serverFn/')
if (document) {
const location = owner
? '/onboarding'
: readAuthState().needsSetup
? '/setup'
: '/login'
return new Response(null, {
status: 303,
headers: { location, 'cache-control': 'no-store' },
})
}
return Response.json(
{
error: owner
? 'Complete onboarding to continue.'
: 'Sign in to continue.',
},
{ status: owner ? 403 : 401, headers: { 'cache-control': 'no-store' } },
)
}
+103
View File
@@ -0,0 +1,103 @@
import {
AuthError,
completeOnboarding,
createOwner,
getSession,
readAuthState,
SESSION_MAX_AGE_SECONDS,
signIn,
signOut,
} from './auth.server'
const SESSION_COOKIE = 'workspace_session'
export function sessionToken(request: Request) {
return request.headers
.get('cookie')
?.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`${SESSION_COOKIE}=`))
?.slice(SESSION_COOKIE.length + 1)
}
export function authState(request: Request) {
return { ...readAuthState(), owner: getSession(sessionToken(request)) }
}
function cookie(token: string, clear = false) {
const secure = process.env.TWITTER_LITE_ORIGIN?.startsWith('https:')
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_MAX_AGE_SECONDS}${secure ? '; Secure' : ''}`
}
export async function authEndpoint(request: Request) {
const headers = new Headers({
'cache-control': 'no-store',
vary: 'Cookie',
'content-type': 'application/json',
})
try {
if (request.method === 'GET')
return Response.json(authState(request), { headers })
if (request.method !== 'POST')
return new Response(null, { status: 405, headers })
if (!request.headers.get('content-type')?.startsWith('application/json'))
throw new AuthError(400, 'Use a JSON request.')
const body = await request.text()
if (body.length > 8192) throw new AuthError(413, 'Request is too large.')
let data: Record<string, unknown>
try {
const parsed = JSON.parse(body)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('invalid body')
data = parsed
} catch {
throw new AuthError(400, 'Check the form and try again.')
}
const text = (key: string) =>
typeof data[key] === 'string' ? data[key] : ''
const oldToken = sessionToken(request)
switch (data.action) {
case 'setup':
case 'login': {
const result =
data.action === 'setup'
? await createOwner({
email: text('email'),
password: text('password'),
name: text('name'),
setupToken: text('setupToken'),
})
: await signIn({ email: text('email'), password: text('password') })
signOut(oldToken)
headers.set('set-cookie', cookie(result.sessionToken))
return Response.json(
{ needsSetup: false, owner: result.owner },
{ headers },
)
}
case 'onboard': {
const owner = completeOnboarding(oldToken ?? '', { name: text('name') })
return Response.json({ needsSetup: false, owner }, { headers })
}
case 'logout':
signOut(oldToken)
headers.set('set-cookie', cookie('', true))
return Response.json(
{ needsSetup: readAuthState().needsSetup, owner: null },
{ headers },
)
default:
throw new AuthError(400, 'Unknown action.')
}
} catch (error) {
if (error instanceof AuthError)
return Response.json(
{ error: error.message },
{ status: error.status, headers },
)
return Response.json(
{ error: 'Unable to complete the request. Please try again.' },
{ status: 500, headers },
)
}
}
+151
View File
@@ -0,0 +1,151 @@
import { useRouteContext } from '@tanstack/react-router'
import { ArrowRight, BookOpen, House, MessagesSquare } from 'lucide-react'
import { type FormEvent, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { authRequest } from './auth-client'
import { AuthFrame } from './auth-page'
export function OnboardingPage() {
const { auth } = useRouteContext({ from: '__root__' })
const [name, setName] = useState(auth.owner?.name ?? '')
const [step, setStep] = useState(1)
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
async function finish() {
setPending(true)
setError('')
try {
await authRequest({ action: 'onboard', name: name.trim() })
window.location.assign('/')
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to save. Please try again.',
)
setPending(false)
}
}
async function signOut() {
setPending(true)
setError('')
try {
await authRequest({ action: 'logout' })
window.location.assign('/login')
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to sign out. Please try again.',
)
setPending(false)
}
}
function next(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!name.trim()) return
setStep(2)
}
return (
<AuthFrame>
<p className="auth-step">{step} of 2</p>
{step === 1 ? (
<>
<header className="auth-heading">
<h1>A little context, to start.</h1>
<p>Your day, conversations, and reading, together in one place.</p>
</header>
<form className="auth-form" onSubmit={next}>
<div className="auth-field">
<Label htmlFor="displayName">What should we call you?</Label>
<Input
id="displayName"
name="displayName"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="given-name"
required
maxLength={80}
disabled={pending}
/>
</div>
<Button
type="submit"
className="auth-submit"
size="lg"
disabled={pending || !name.trim()}
>
Continue
<ArrowRight aria-hidden="true" />
</Button>
</form>
</>
) : (
<>
<header className="auth-heading">
<h1>Start with today.</h1>
<p>You can take it one thing at a time.</p>
</header>
<ul className="auth-overview">
<li>
<House aria-hidden="true" />
<div>
<strong>A place to begin</strong>
<p>A brief and flexible tasks help you find your next step.</p>
</div>
</li>
<li>
<MessagesSquare aria-hidden="true" />
<div>
<strong>Room for the everyday</strong>
<p>Keep conversations and quick notes close at hand.</p>
</div>
</li>
<li>
<BookOpen aria-hidden="true" />
<div>
<strong>Follow your curiosity</strong>
<p>Explore reading and research alongside your day.</p>
</div>
</li>
</ul>
<p className="auth-prototype">
Home, contacts, notes, reading, and vitals currently use sample
data. You can add connections from your profile menu whenever you’re
ready.
</p>
<div className="auth-actions">
<Button
variant="ghost"
onClick={() => setStep(1)}
disabled={pending}
>
Back
</Button>
<Button size="lg" onClick={finish} disabled={pending}>
{pending ? 'Saving…' : 'Open workspace'}
<ArrowRight aria-hidden="true" />
</Button>
</div>
</>
)}
{error && (
<p className="auth-error" role="alert">
{error}
</p>
)}
<footer className="auth-footer">
<span>{auth.owner?.email}</span>
<Button variant="ghost" size="sm" onClick={signOut} disabled={pending}>
Sign out
</Button>
</footer>
</AuthFrame>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { createIsomorphicFn } from '@tanstack/react-start'
import type { AuthState } from './auth-client'
export const loadAuthState = createIsomorphicFn()
.server(async (): Promise<AuthState> => {
const { getRequest } = await import('@tanstack/react-start/server')
const { authState } = await import('./http.server')
return authState(getRequest())
})
.client(async (): Promise<AuthState> => {
const response = await fetch('/api/auth', { cache: 'no-store' })
if (!response.ok) throw new Error('Unable to check your session.')
return response.json()
})
@@ -1,7 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { useRouteContext } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start'
import { useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { authRequest } from '#/features/auth/auth-client'
import { ConnectionManager } from './connection-manager'
import { loadConnections } from './server-functions'
@@ -12,6 +15,20 @@ export function ConnectionManagerDialog({
onClose: () => void
hasTemporaryDecks?: boolean
}) {
const { auth } = useRouteContext({ from: '__root__' })
const [signingOut, setSigningOut] = useState(false)
const [error, setError] = useState('')
async function logout() {
setSigningOut(true)
setError('')
try {
await authRequest({ action: 'logout' })
window.location.assign('/login')
} catch {
setError('Unable to sign out. Please try again.')
setSigningOut(false)
}
}
const fetchConnections = useServerFn(loadConnections)
const connections = useQuery({
queryKey: ['connections'],
@@ -25,6 +42,22 @@ export function ConnectionManagerDialog({
onClose={onClose}
className="sm:max-w-2xl"
>
<div className="mb-5 flex items-center justify-between gap-4 border-b pb-4">
<div className="min-w-0">
<p className="truncate font-medium">{auth.owner?.name}</p>
<p className="truncate text-sm text-muted-foreground">
{auth.owner?.email}
</p>
</div>
<Button
variant="outline"
disabled={signingOut}
onClick={() => void logout()}
>
{signingOut ? 'Signing out…' : 'Sign out'}
</Button>
</div>
{error && <p role="alert">{error}</p>}
{connections.isPending ? (
<p role="status">Loading connected accounts…</p>
) : connections.isError ? (
+3 -2
View File
@@ -1,4 +1,4 @@
import { Link, useNavigate } from '@tanstack/react-router'
import { Link, useNavigate, useRouteContext } from '@tanstack/react-router'
import {
ArrowDown,
ArrowUp,
@@ -32,6 +32,7 @@ import { type HomeAction, interpretAction } from './interpret-action'
import './home.css'
export function HomePage() {
const { auth } = useRouteContext({ from: '__root__' })
const {
tasks,
setTasks,
@@ -175,7 +176,7 @@ export function HomePage() {
<div className="home-content">
<header className="home-greeting">
<p className="text-sm text-muted-foreground">Today</p>
<h1>Good morning, Yuta</h1>
<h1>Good morning, {auth.owner?.name}</h1>
<DailyBrief onOpenContact={setContactId} />
</header>
<form
+18 -1
View File
@@ -4,7 +4,11 @@ type Snapshot = { configured: boolean; run: ResearchRun | null }
type Subscribe = (listener: (snapshot: Snapshot) => void) => () => void
/** Subscribe to current state and subsequent changes; disconnect only this viewer. */
export function researchEvents(request: Request, subscribe: Subscribe) {
export function researchEvents(
request: Request,
subscribe: Subscribe,
isAuthorized: () => boolean = () => true,
) {
const encoder = new TextEncoder()
let dispose = () => {}
let flushPending = () => {}
@@ -14,7 +18,15 @@ export function researchEvents(request: Request, subscribe: Subscribe) {
let closed = false
let unsubscribe = () => {}
let heartbeat: ReturnType<typeof setInterval> | undefined
const checkSession = () => {
if (closed) return false
if (isAuthorized()) return true
dispose()
controller.close()
return false
}
const flush = () => {
if (!checkSession()) return
if (
!closed &&
pending !== undefined &&
@@ -46,7 +58,12 @@ export function researchEvents(request: Request, subscribe: Subscribe) {
pending = `data: ${JSON.stringify(snapshot)}\n\n`
flush()
})
if (closed) {
unsubscribe()
return
}
heartbeat = setInterval(() => {
if (!checkSession()) return
if (
!closed &&
pending === undefined &&
+22
View File
@@ -81,3 +81,25 @@ it('bounds a slow viewer buffer while retaining the latest state', async () => {
)
await reader.cancel()
})
it('stops sending updates when its login session is revoked', async () => {
let authorized = true
let emit = (_value: { configured: boolean; run: null }) => {}
const unsubscribe = vi.fn()
const response = researchEvents(
new Request('http://127.0.0.1/events'),
(listener) => {
emit = listener
listener({ configured: false, run: null })
return unsubscribe
},
() => authorized,
)
const reader = response.body?.getReader()
expect.assert.isDefined(reader)
await reader.read()
authorized = false
emit({ configured: true, run: null })
expect((await reader.read()).done).toBe(true)
expect(unsubscribe).toHaveBeenCalledOnce()
})
@@ -42,4 +42,14 @@ export const migrations = [
folderMillis: 1790242505709,
hash: '5b74e60a806df54dc3c6897aa84305b80a3b097dd70cd6cf1feebcfd3b72a135',
},
{
sql: [
'CREATE TABLE `auth_sessions` (\n\t`token_hash` text PRIMARY KEY NOT NULL,\n\t`owner_id` integer NOT NULL,\n\t`expires_at` integer NOT NULL,\n\tFOREIGN KEY (`owner_id`) REFERENCES `workspace_owner`(`id`) ON UPDATE no action ON DELETE cascade\n);\n',
'\nCREATE TABLE `auth_throttle` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`attempts` integer NOT NULL,\n\t`reset_at` integer NOT NULL,\n\tCONSTRAINT "auth_throttle_singleton" CHECK("auth_throttle"."id" = 1)\n);\n',
'\nCREATE TABLE `workspace_owner` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`email` text NOT NULL,\n\t`name` text NOT NULL,\n\t`password_hash` text NOT NULL,\n\t`onboarding_completed_at` integer,\n\t`created_at` integer NOT NULL,\n\tCONSTRAINT "workspace_owner_singleton" CHECK("workspace_owner"."id" = 1)\n);\n',
],
bps: true,
folderMillis: 1790589494499,
hash: 'c2093b5a2e4f84ffa333dff896fc8a7bf86015ce84ae562765b525b763af749e',
},
]
+31
View File
@@ -146,3 +146,34 @@ export const researchState = sqliteTable(
},
(table) => [check('research_state_singleton', sql`${table.id} = 1`)],
)
export const workspaceOwner = sqliteTable(
'workspace_owner',
{
id: integer('id').primaryKey(),
email: text('email').notNull(),
name: text('name').notNull(),
passwordHash: text('password_hash').notNull(),
onboardingCompletedAt: integer('onboarding_completed_at'),
createdAt: integer('created_at').notNull(),
},
(table) => [check('workspace_owner_singleton', sql`${table.id} = 1`)],
)
export const authSessions = sqliteTable('auth_sessions', {
tokenHash: text('token_hash').primaryKey(),
ownerId: integer('owner_id')
.notNull()
.references(() => workspaceOwner.id, { onDelete: 'cascade' }),
expiresAt: integer('expires_at').notNull(),
})
export const authThrottle = sqliteTable(
'auth_throttle',
{
id: integer('id').primaryKey(),
attempts: integer('attempts').notNull(),
resetAt: integer('reset_at').notNull(),
},
(table) => [check('auth_throttle_singleton', sql`${table.id} = 1`)],
)
+6 -1
View File
@@ -1,3 +1,4 @@
import { useRouteContext } from '@tanstack/react-router'
import {
Activity,
Bell,
@@ -52,6 +53,7 @@ const nav = [
{ name: 'Profile', icon: UserRound },
]
export function VitalsPage() {
const { auth } = useRouteContext({ from: '__root__' })
const [section, setSection] = useState('Body')
const [mobileMenu, setMobileMenu] = useState(false)
const [detail, setDetail] = useState<string>()
@@ -141,7 +143,10 @@ export function VitalsPage() {
className="vital-profile"
onClick={() => setDetail('Profile')}
>
<span className="vital-avatar">Y</span>Yuta
<span className="vital-avatar">
{auth.owner?.name.slice(0, 1).toUpperCase()}
</span>
{auth.owner?.name}
<ChevronDown />
</button>
</div>
+84
View File
@@ -11,10 +11,14 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as VitalsRouteImport } from './routes/vitals'
import { Route as SupportRouteImport } from './routes/support'
import { Route as SetupRouteImport } from './routes/setup'
import { Route as OnboardingRouteImport } from './routes/onboarding'
import { Route as LoginRouteImport } from './routes/login'
import { Route as JournalRouteImport } from './routes/journal'
import { Route as InboxRouteImport } from './routes/inbox'
import { Route as DeckRouteImport } from './routes/deck'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiAuthRouteImport } from './routes/api/auth'
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
@@ -28,6 +32,21 @@ const SupportRoute = SupportRouteImport.update({
path: '/support',
getParentRoute: () => rootRouteImport,
} as any)
const SetupRoute = SetupRouteImport.update({
id: '/setup',
path: '/setup',
getParentRoute: () => rootRouteImport,
} as any)
const OnboardingRoute = OnboardingRouteImport.update({
id: '/onboarding',
path: '/onboarding',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const JournalRoute = JournalRouteImport.update({
id: '/journal',
path: '/journal',
@@ -48,6 +67,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthRoute = ApiAuthRouteImport.update({
id: '/api/auth',
path: '/api/auth',
getParentRoute: () => rootRouteImport,
} as any)
const OauthMastodonCallbackRoute = OauthMastodonCallbackRouteImport.update({
id: '/oauth/mastodon/callback',
path: '/oauth/mastodon/callback',
@@ -64,8 +88,12 @@ export interface FileRoutesByFullPath {
'/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/journal': typeof JournalRoute
'/login': typeof LoginRoute
'/onboarding': typeof OnboardingRoute
'/setup': typeof SetupRoute
'/support': typeof SupportRoute
'/vitals': typeof VitalsRoute
'/api/auth': typeof ApiAuthRoute
'/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
@@ -74,8 +102,12 @@ export interface FileRoutesByTo {
'/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/journal': typeof JournalRoute
'/login': typeof LoginRoute
'/onboarding': typeof OnboardingRoute
'/setup': typeof SetupRoute
'/support': typeof SupportRoute
'/vitals': typeof VitalsRoute
'/api/auth': typeof ApiAuthRoute
'/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
@@ -85,8 +117,12 @@ export interface FileRoutesById {
'/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/journal': typeof JournalRoute
'/login': typeof LoginRoute
'/onboarding': typeof OnboardingRoute
'/setup': typeof SetupRoute
'/support': typeof SupportRoute
'/vitals': typeof VitalsRoute
'/api/auth': typeof ApiAuthRoute
'/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
@@ -97,8 +133,12 @@ export interface FileRouteTypes {
| '/deck'
| '/inbox'
| '/journal'
| '/login'
| '/onboarding'
| '/setup'
| '/support'
| '/vitals'
| '/api/auth'
| '/api/research/events'
| '/oauth/mastodon/callback'
fileRoutesByTo: FileRoutesByTo
@@ -107,8 +147,12 @@ export interface FileRouteTypes {
| '/deck'
| '/inbox'
| '/journal'
| '/login'
| '/onboarding'
| '/setup'
| '/support'
| '/vitals'
| '/api/auth'
| '/api/research/events'
| '/oauth/mastodon/callback'
id:
@@ -117,8 +161,12 @@ export interface FileRouteTypes {
| '/deck'
| '/inbox'
| '/journal'
| '/login'
| '/onboarding'
| '/setup'
| '/support'
| '/vitals'
| '/api/auth'
| '/api/research/events'
| '/oauth/mastodon/callback'
fileRoutesById: FileRoutesById
@@ -128,8 +176,12 @@ export interface RootRouteChildren {
DeckRoute: typeof DeckRoute
InboxRoute: typeof InboxRoute
JournalRoute: typeof JournalRoute
LoginRoute: typeof LoginRoute
OnboardingRoute: typeof OnboardingRoute
SetupRoute: typeof SetupRoute
SupportRoute: typeof SupportRoute
VitalsRoute: typeof VitalsRoute
ApiAuthRoute: typeof ApiAuthRoute
ApiResearchEventsRoute: typeof ApiResearchEventsRoute
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
}
@@ -150,6 +202,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SupportRouteImport
parentRoute: typeof rootRouteImport
}
'/setup': {
id: '/setup'
path: '/setup'
fullPath: '/setup'
preLoaderRoute: typeof SetupRouteImport
parentRoute: typeof rootRouteImport
}
'/onboarding': {
id: '/onboarding'
path: '/onboarding'
fullPath: '/onboarding'
preLoaderRoute: typeof OnboardingRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/journal': {
id: '/journal'
path: '/journal'
@@ -178,6 +251,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth': {
id: '/api/auth'
path: '/api/auth'
fullPath: '/api/auth'
preLoaderRoute: typeof ApiAuthRouteImport
parentRoute: typeof rootRouteImport
}
'/oauth/mastodon/callback': {
id: '/oauth/mastodon/callback'
path: '/oauth/mastodon/callback'
@@ -200,8 +280,12 @@ const rootRouteChildren: RootRouteChildren = {
DeckRoute: DeckRoute,
InboxRoute: InboxRoute,
JournalRoute: JournalRoute,
LoginRoute: LoginRoute,
OnboardingRoute: OnboardingRoute,
SetupRoute: SetupRoute,
SupportRoute: SupportRoute,
VitalsRoute: VitalsRoute,
ApiAuthRoute: ApiAuthRoute,
ApiResearchEventsRoute: ApiResearchEventsRoute,
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
}
+22
View File
@@ -3,14 +3,36 @@ import {
createRootRouteWithContext,
HeadContent,
Outlet,
redirect,
Scripts,
} from '@tanstack/react-router'
import { loadAuthState } from '#/features/auth/session'
import { WorkspaceStateProvider } from '#/features/workspace/workspace-state'
import appCss from '../styles.css?url'
type RouterContext = { queryClient: QueryClient }
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: async ({ location }) => {
const auth = await loadAuthState()
const path = location.pathname
const entry = path === '/login' || path === '/setup'
if (!auth.owner && auth.needsSetup && path === '/login')
throw redirect({ to: '/setup' })
if (!auth.owner && !entry)
throw redirect({ to: auth.needsSetup ? '/setup' : '/login' })
if (!auth.owner && path === '/setup' && !auth.needsSetup)
throw redirect({ to: '/login' })
if (
auth.owner &&
!auth.owner.onboardingCompletedAt &&
path !== '/onboarding'
)
throw redirect({ to: '/onboarding' })
if (auth.owner?.onboardingCompletedAt && (entry || path === '/onboarding'))
throw redirect({ to: '/' })
return { auth }
},
head: () => ({
meta: [
{ charSet: 'utf-8' },
+10
View File
@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
async function handle({ request }: { request: Request }) {
const { authEndpoint } = await import('../../features/auth/http.server')
return authEndpoint(request)
}
export const Route = createFileRoute('/api/auth')({
server: { handlers: { GET: handle, POST: handle } },
})
+10 -1
View File
@@ -8,7 +8,16 @@ export const Route = createFileRoute('/api/research/events')({
import('../../../features/research/events.server'),
import('../../../features/research/runner.server'),
])
return researchEvents(request, researchService().subscribe)
const { getSession } = await import(
'../../../features/auth/auth.server'
)
const { sessionToken } = await import(
'../../../features/auth/http.server'
)
const token = sessionToken(request)
return researchEvents(request, researchService().subscribe, () =>
Boolean(getSession(token)?.onboardingCompletedAt),
)
},
},
},
+3
View File
@@ -0,0 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { LoginPage } from '#/features/auth/auth-page'
export const Route = createFileRoute('/login')({ component: LoginPage })
+5
View File
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { OnboardingPage } from '#/features/auth/onboarding-page'
export const Route = createFileRoute('/onboarding')({
component: OnboardingPage,
})
+3
View File
@@ -0,0 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { SetupPage } from '#/features/auth/auth-page'
export const Route = createFileRoute('/setup')({ component: SetupPage })
+8 -1
View File
@@ -15,6 +15,13 @@ const csrf = createCsrfMiddleware({
filter: (context) => context.handlerType === 'serverFn',
})
const appSession = createMiddleware().server(async ({ request, next }) => {
const { checkSessionAccess } = await import('./features/auth/gate.server')
const { setResponseHeader } = await import('@tanstack/react-start/server')
setResponseHeader('Cache-Control', 'no-store')
return checkSessionAccess(request) ?? next()
})
const storage = createMiddleware().server(async ({ next }) => {
const { getDatabase } = await import('./features/storage/database.server')
getDatabase()
@@ -22,5 +29,5 @@ const storage = createMiddleware().server(async ({ next }) => {
})
export const startInstance = createStart(() => ({
requestMiddleware: [ownerAccess, csrf, storage],
requestMiddleware: [ownerAccess, csrf, storage, appSession],
}))