feat: add URL-driven workspace state and streamed Codex research
This commit is contained in:
@@ -1,34 +1,34 @@
|
||||
export type SafeOwner = {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
onboardingCompletedAt: number | null
|
||||
}
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
onboardingCompletedAt: number | null;
|
||||
};
|
||||
|
||||
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null }
|
||||
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null };
|
||||
|
||||
type AuthInput =
|
||||
| { action: 'login'; email: string; password: string }
|
||||
| { action: "login"; email: string; password: string }
|
||||
| {
|
||||
action: 'setup'
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
setupToken: string
|
||||
action: "setup";
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
setupToken: string;
|
||||
}
|
||||
| { action: 'logout' }
|
||||
| { action: 'onboard'; name: 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' },
|
||||
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()
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Something went wrong. Please try again.')
|
||||
throw new Error(result.error || "Something went wrong. Please try again.");
|
||||
}
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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'
|
||||
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 { safeReturnTo } from "./return-to";
|
||||
|
||||
export function AuthFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
@@ -17,78 +17,70 @@ export function AuthFrame({ children }: { children: ReactNode }) {
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
return <CredentialsForm setup={false} />
|
||||
return <CredentialsForm setup={false} />;
|
||||
}
|
||||
|
||||
export function SetupPage() {
|
||||
return <CredentialsForm setup />
|
||||
return <CredentialsForm setup />;
|
||||
}
|
||||
|
||||
function formText(data: FormData, name: string) {
|
||||
const value = data.get(name);
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function CredentialsForm({ setup }: { setup: boolean }) {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
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
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const data = new FormData(event.currentTarget);
|
||||
const email = formText(data, "email").trim();
|
||||
const password = formText(data, "password");
|
||||
if (setup && password !== data.get("confirmPassword")) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
setPending(true)
|
||||
setError('')
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const state = await authRequest(
|
||||
setup
|
||||
? {
|
||||
action: 'setup',
|
||||
action: "setup",
|
||||
email,
|
||||
password,
|
||||
name: String(data.get('name') ?? '').trim(),
|
||||
setupToken: String(data.get('setupToken') ?? '').trim(),
|
||||
name: formText(data, "name").trim(),
|
||||
setupToken: formText(data, "setupToken").trim(),
|
||||
}
|
||||
: { action: 'login', email, password },
|
||||
)
|
||||
: { action: "login", email, password },
|
||||
);
|
||||
if (!state.owner?.onboardingCompletedAt) {
|
||||
window.location.assign('/onboarding')
|
||||
return
|
||||
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)
|
||||
const returnTo = new URLSearchParams(window.location.search).get("returnTo");
|
||||
window.location.assign(safeReturnTo(returnTo));
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'Unable to sign in. Please try again.',
|
||||
)
|
||||
setPending(false)
|
||||
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>
|
||||
<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.'}
|
||||
? "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}>
|
||||
@@ -123,11 +115,11 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete={setup ? 'new-password' : 'current-password'}
|
||||
autoComplete={setup ? "new-password" : "current-password"}
|
||||
required
|
||||
minLength={setup ? 15 : undefined}
|
||||
maxLength={128}
|
||||
aria-describedby={setup ? 'password-hint' : undefined}
|
||||
aria-describedby={setup ? "password-hint" : undefined}
|
||||
disabled={pending}
|
||||
/>
|
||||
{setup && (
|
||||
@@ -173,27 +165,22 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={pending}
|
||||
className="auth-submit"
|
||||
>
|
||||
<Button type="submit" size="lg" disabled={pending} className="auth-submit">
|
||||
{pending
|
||||
? setup
|
||||
? 'Creating account…'
|
||||
: 'Signing in…'
|
||||
? "Creating account…"
|
||||
: "Signing in…"
|
||||
: setup
|
||||
? 'Create account'
|
||||
: 'Sign in'}
|
||||
? "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.'}
|
||||
? "This workspace has one owner. Connections can be added later."
|
||||
: "A private workspace. Access is limited to its owner."}
|
||||
</p>
|
||||
</AuthFrame>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,93 +1,83 @@
|
||||
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'
|
||||
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
|
||||
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
|
||||
);
|
||||
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'
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
||||
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)))
|
||||
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(),
|
||||
}
|
||||
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
|
||||
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
|
||||
throw new AuthError(503, "The setup token must contain at least 32 characters.");
|
||||
return configured;
|
||||
}
|
||||
const dbPath = process.env.TWITTER_LITE_DB_PATH
|
||||
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 })
|
||||
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'), {
|
||||
writeFileSync(path, randomBytes(32).toString("base64url"), {
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
})
|
||||
flag: "wx",
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw 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
|
||||
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()
|
||||
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.',
|
||||
)
|
||||
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()
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(authThrottle)
|
||||
.values({ id: 1, attempts: 1, resetAt: now + throttleWindow })
|
||||
@@ -95,21 +85,18 @@ function consumeAttempt(database: AppDatabase) {
|
||||
target: authThrottle.id,
|
||||
set: { attempts: 1, resetAt: now + throttleWindow },
|
||||
})
|
||||
.run()
|
||||
.run();
|
||||
}
|
||||
},
|
||||
{ behavior: 'immediate' },
|
||||
)
|
||||
{ behavior: "immediate" },
|
||||
);
|
||||
}
|
||||
function session(
|
||||
database: Pick<AppDatabase, 'insert' | 'delete'>,
|
||||
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()
|
||||
const sessionToken = randomBytes(32).toString("base64url");
|
||||
database.delete(authSessions).where(lte(authSessions.expiresAt, Date.now())).run();
|
||||
database
|
||||
.insert(authSessions)
|
||||
.values({
|
||||
@@ -117,125 +104,106 @@ function session(
|
||||
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) }
|
||||
.run();
|
||||
database.delete(authThrottle).where(eq(authThrottle.id, 1)).run();
|
||||
return { sessionToken, owner: safeOwner(owner) };
|
||||
}
|
||||
function cleanName(name: string) {
|
||||
const cleaned = name.trim()
|
||||
const cleaned = name.trim();
|
||||
if (!cleaned || cleaned.length > 80)
|
||||
throw new AuthError(400, 'Enter a name of up to 80 characters.')
|
||||
return cleaned
|
||||
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)
|
||||
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()
|
||||
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.')
|
||||
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
|
||||
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.',
|
||||
)
|
||||
throw new AuthError(409, "This workspace is already set up. Please sign in.");
|
||||
const owner = {
|
||||
id: 1,
|
||||
email,
|
||||
name,
|
||||
passwordHash: `${salt}:${derived.toString('hex')}`,
|
||||
passwordHash: `${salt}:${derived.toString("hex")}`,
|
||||
onboardingCompletedAt: null,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
tx.insert(workspaceOwner).values(owner).run()
|
||||
return session(tx, owner)
|
||||
};
|
||||
tx.insert(workspaceOwner).values(owner).run();
|
||||
return session(tx, owner);
|
||||
},
|
||||
{ behavior: 'immediate' },
|
||||
)
|
||||
{ behavior: "immediate" },
|
||||
);
|
||||
}
|
||||
export async function signIn(
|
||||
input: { email: string; password: string },
|
||||
database = getDatabase(),
|
||||
) {
|
||||
consumeAttempt(database)
|
||||
const owner = database.select().from(workspaceOwner).get()
|
||||
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
|
||||
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'))
|
||||
!timingSafeEqual(actual, Buffer.from(expected, "hex"))
|
||||
)
|
||||
throw new AuthError(401, 'Invalid email or password.')
|
||||
return session(database, owner)
|
||||
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
|
||||
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
|
||||
.get();
|
||||
if (!found) return null;
|
||||
if (found.expiresAt <= Date.now()) {
|
||||
database
|
||||
.delete(authSessions)
|
||||
.where(eq(authSessions.tokenHash, found.tokenHash))
|
||||
.run()
|
||||
return null
|
||||
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
|
||||
.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()
|
||||
.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 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 }
|
||||
};
|
||||
database.update(workspaceOwner).set(update).where(eq(workspaceOwner.id, owner.id)).run();
|
||||
return { ...owner, ...update };
|
||||
}
|
||||
|
||||
+102
-119
@@ -1,10 +1,10 @@
|
||||
// @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 { 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,
|
||||
@@ -14,137 +14,120 @@ import {
|
||||
SESSION_MAX_AGE_SECONDS,
|
||||
signIn,
|
||||
signOut,
|
||||
} from './auth.server'
|
||||
} from "./auth.server";
|
||||
|
||||
let database: ReturnType<typeof openDatabase>
|
||||
let database: ReturnType<typeof openDatabase>;
|
||||
const credentials = {
|
||||
email: '[email protected]',
|
||||
password: 'correct horse battery staple',
|
||||
name: 'Owner',
|
||||
setupToken: 's'.repeat(32),
|
||||
}
|
||||
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)
|
||||
})
|
||||
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 })
|
||||
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)
|
||||
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 () => {
|
||||
});
|
||||
});
|
||||
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)
|
||||
]);
|
||||
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, password: 'wrong' }, database),
|
||||
signIn({ ...credentials, email: "[email protected]" }, 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 () => {
|
||||
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 })
|
||||
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 () => {
|
||||
});
|
||||
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-'))
|
||||
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)
|
||||
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 })
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
import { getSession, readAuthState } from './auth.server'
|
||||
import { sessionToken } from './http.server'
|
||||
import { getSession, readAuthState } from "./auth.server";
|
||||
import { sessionToken } from "./http.server";
|
||||
import { safeReturnTo } from "./return-to";
|
||||
|
||||
const publicPaths = new Set(['/login', '/setup', '/api/auth'])
|
||||
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 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/')
|
||||
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'
|
||||
const location = owner ? "/onboarding" : readAuthState().needsSetup ? "/setup" : "/login";
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: { location, 'cache-control': 'no-store' },
|
||||
})
|
||||
headers: {
|
||||
location:
|
||||
location === "/login" && (url.pathname !== "/" || !!url.search)
|
||||
? `/login?${new URLSearchParams({ returnTo: safeReturnTo(url.pathname + url.search) })}`
|
||||
: location,
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
return Response.json(
|
||||
{
|
||||
error: owner
|
||||
? 'Complete onboarding to continue.'
|
||||
: 'Sign in to continue.',
|
||||
error: owner ? "Complete onboarding to continue." : "Sign in to continue.",
|
||||
},
|
||||
{ status: owner ? 403 : 401, headers: { 'cache-control': 'no-store' } },
|
||||
)
|
||||
{ status: owner ? 403 : 401, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,97 +7,85 @@ import {
|
||||
SESSION_MAX_AGE_SECONDS,
|
||||
signIn,
|
||||
signOut,
|
||||
} from './auth.server'
|
||||
} from "./auth.server";
|
||||
|
||||
const SESSION_COOKIE = 'workspace_session'
|
||||
const SESSION_COOKIE = "workspace_session";
|
||||
|
||||
export function sessionToken(request: Request) {
|
||||
return request.headers
|
||||
.get('cookie')
|
||||
?.split(';')
|
||||
.get("cookie")
|
||||
?.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${SESSION_COOKIE}=`))
|
||||
?.slice(SESSION_COOKIE.length + 1)
|
||||
?.slice(SESSION_COOKIE.length + 1);
|
||||
}
|
||||
|
||||
export function authState(request: Request) {
|
||||
return { ...readAuthState(), owner: getSession(sessionToken(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' : ''}`
|
||||
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',
|
||||
})
|
||||
"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>
|
||||
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
|
||||
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.')
|
||||
throw new AuthError(400, "Check the form and try again.");
|
||||
}
|
||||
const text = (key: string) =>
|
||||
typeof data[key] === 'string' ? data[key] : ''
|
||||
const oldToken = sessionToken(request)
|
||||
const text = (key: string) => (typeof data[key] === "string" ? data[key] : "");
|
||||
const oldToken = sessionToken(request);
|
||||
switch (data.action) {
|
||||
case 'setup':
|
||||
case 'login': {
|
||||
case "setup":
|
||||
case "login": {
|
||||
const result =
|
||||
data.action === 'setup'
|
||||
data.action === "setup"
|
||||
? await createOwner({
|
||||
email: text('email'),
|
||||
password: text('password'),
|
||||
name: text('name'),
|
||||
setupToken: text('setupToken'),
|
||||
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 },
|
||||
)
|
||||
: 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 "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 },
|
||||
)
|
||||
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.')
|
||||
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: error.message }, { status: error.status, headers });
|
||||
return Response.json(
|
||||
{ error: 'Unable to complete the request. Please try again.' },
|
||||
{ error: "Unable to complete the request. Please try again." },
|
||||
{ status: 500, headers },
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,47 @@
|
||||
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'
|
||||
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('')
|
||||
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('')
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await authRequest({ action: 'onboard', name: name.trim() })
|
||||
window.location.assign('/')
|
||||
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)
|
||||
setError(cause instanceof Error ? cause.message : "Unable to save. Please try again.");
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
setPending(true)
|
||||
setError('')
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await authRequest({ action: 'logout' })
|
||||
window.location.assign('/login')
|
||||
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)
|
||||
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)
|
||||
event.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -116,20 +108,15 @@ export function OnboardingPage() {
|
||||
</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.
|
||||
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}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => setStep(1)} disabled={pending}>
|
||||
Back
|
||||
</Button>
|
||||
<Button size="lg" onClick={finish} disabled={pending}>
|
||||
{pending ? 'Saving…' : 'Open workspace'}
|
||||
{pending ? "Saving…" : "Open workspace"}
|
||||
<ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -147,5 +134,5 @@ export function OnboardingPage() {
|
||||
</Button>
|
||||
</footer>
|
||||
</AuthFrame>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { safeReturnTo } from "./return-to";
|
||||
|
||||
it("keeps the selected view through login", () => {
|
||||
expect(safeReturnTo("/inbox?q=agents&article=browser#read")).toBe(
|
||||
"/inbox?q=agents&article=browser#read",
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
"https://outside.invalid",
|
||||
"//outside.invalid",
|
||||
"/\\outside.invalid",
|
||||
"/api/auth",
|
||||
"/login",
|
||||
"/inbox\n",
|
||||
undefined,
|
||||
])("rejects an unsafe destination: %s", (value) => {
|
||||
expect(safeReturnTo(value)).toBe("/");
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
const paths = new Set(["/", "/deck", "/support", "/journal", "/inbox", "/vitals"]);
|
||||
|
||||
export function safeReturnTo(value: unknown): string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!value.startsWith("/") ||
|
||||
value.startsWith("//") ||
|
||||
/[\\\r\n]/.test(value)
|
||||
)
|
||||
return "/";
|
||||
const url = new URL(value, "https://workspace.invalid");
|
||||
return url.origin === "https://workspace.invalid" && paths.has(url.pathname)
|
||||
? `${url.pathname}${url.search}${url.hash}`
|
||||
: "/";
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createIsomorphicFn } from '@tanstack/react-start'
|
||||
import type { AuthState } from './auth-client'
|
||||
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())
|
||||
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()
|
||||
})
|
||||
const response = await fetch("/api/auth", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Unable to check your session.");
|
||||
return response.json();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user