Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@tripwire/auth": "workspace:*",
"@tripwire/core": "workspace:*",
"@tripwire/db": "workspace:*",
"@tripwire/email": "workspace:*",
"@tripwire/env": "workspace:*",
"@tripwire/feedback": "workspace:*",
"@tripwire/github": "workspace:*",
Expand Down
42 changes: 42 additions & 0 deletions apps/web/scripts/backfill-access-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* One-time backfill: move every pre-existing user to `approved` so the
* access gate doesn't lock out people who were already using Tripwire.
*
* Run this in the same window as the schema push (rollout step 1), BEFORE
* turning on ACCESS_GATE_ENABLED. Newly added `access_status` columns default
* to "pending", so without this every existing user would be gated.
*
* Only approves users created before the script started, so it stays safe to
* re-run even after real pending signups exist.
*
* pnpm --filter @tripwire/web exec tsx scripts/backfill-access-status.ts
*/
import { and, eq, lte } from "drizzle-orm"
import { db } from "@tripwire/db/client"
import { user as userTable } from "@tripwire/db"

async function main() {
const cutoff = new Date()

const updated = await db
.update(userTable)
.set({ accessStatus: "approved", updatedAt: new Date() })
.where(
and(
eq(userTable.accessStatus, "pending"),
lte(userTable.createdAt, cutoff)
)
)
.returning({ id: userTable.id })

console.log(
`[backfill-access-status] approved ${updated.length} pre-existing user(s).`
)
}

main()
.then(() => process.exit(0))
.catch((err) => {
console.error("[backfill-access-status] failed:", err)
process.exit(1)
})
77 changes: 77 additions & 0 deletions apps/web/scripts/reset-access-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Reverse of backfill-access-status: move every `approved` user back to
* `pending` (e.g. after everyone was approved by mistake). Clears the review
* metadata so they read as un-reviewed; leaves `waitlistedAt` intact.
*
* SAFE BY DEFAULT: dry-run unless `--apply` is passed. Keeps admins approved
* unless `--include-admins` is passed (so you don't lock yourself out of the
* admin panel once the gate is on).
*
* # see what it would do
* pnpm --filter @tripwire/web exec tsx scripts/reset-access-status.ts
* # actually do it (non-admins only)
* pnpm --filter @tripwire/web exec tsx scripts/reset-access-status.ts --apply
* # include admins too
* pnpm --filter @tripwire/web exec tsx scripts/reset-access-status.ts --apply --include-admins
*/
import { and, eq, ne, sql } from "drizzle-orm"
import { db } from "@tripwire/db/client"
import { user as userTable } from "@tripwire/db"

const APPLY = process.argv.includes("--apply")
const INCLUDE_ADMINS = process.argv.includes("--include-admins")

async function main() {
const [breakdown] = await db
.select({
total: sql<number>`count(*)::int`,
approved: sql<number>`count(*) filter (where ${userTable.accessStatus} = 'approved')::int`,
pending: sql<number>`count(*) filter (where ${userTable.accessStatus} = 'pending')::int`,
rejected: sql<number>`count(*) filter (where ${userTable.accessStatus} = 'rejected')::int`,
approvedAdmins: sql<number>`count(*) filter (where ${userTable.accessStatus} = 'approved' and ${userTable.role} = 'admin')::int`,
})
.from(userTable)

console.log("[reset-access-status] current:", breakdown)

// Target: approved users, excluding admins unless --include-admins.
const where = INCLUDE_ADMINS
? eq(userTable.accessStatus, "approved")
: and(eq(userTable.accessStatus, "approved"), ne(userTable.role, "admin"))

if (!APPLY) {
const targets = await db
.select({ id: userTable.id })
.from(userTable)
.where(where)
console.log(
`[reset-access-status] DRY RUN — would reset ${targets.length} user(s) to "pending"` +
`${INCLUDE_ADMINS ? " (including admins)" : " (admins kept approved)"}.` +
` Re-run with --apply to execute.`
)
return
}

const updated = await db
.update(userTable)
.set({
accessStatus: "pending",
accessReviewedAt: null,
accessReviewedBy: null,
updatedAt: new Date(),
})
.where(where)
.returning({ id: userTable.id })

console.log(
`[reset-access-status] reset ${updated.length} user(s) to "pending"` +
`${INCLUDE_ADMINS ? " (including admins)" : " (admins kept approved)"}.`
)
}

main()
.then(() => process.exit(0))
.catch((err) => {
console.error("[reset-access-status] failed:", err)
process.exit(1)
})
63 changes: 63 additions & 0 deletions apps/web/scripts/waitlist-migration-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* One-time campaign: email everyone on the legacy `waitlist` table asking
* them to sign in with GitHub and claim their spot (rollout step 4).
*
* Idempotent per address (Email SDK idempotency key), so a re-run inside the
* same provider window won't double-send. Retire the `waitlist` table and
* router after the 30-day claim window (rollout step 5).
*
* pnpm --filter @tripwire/web exec tsx scripts/waitlist-migration-email.ts
*/
import { db } from "@tripwire/db/client"
import { waitlist } from "@tripwire/db"
import { email, isEmailConfigured, EMAIL_FROM } from "@tripwire/email"
import { env } from "@tripwire/env/server"

async function main() {
if (!isEmailConfigured) {
console.error("[waitlist-migration] RESEND_API_KEY unset — aborting.")
process.exit(1)
}

const rows = await db.select({ email: waitlist.email }).from(waitlist)
console.log(`[waitlist-migration] sending to ${rows.length} address(es)…`)

let sent = 0
let failed = 0
for (const row of rows) {
try {
await email.send(
{
from: EMAIL_FROM,
to: row.email,
subject: "Claim your Tripwire spot — sign in with GitHub",
text: [
"Tripwire access now runs through GitHub sign-in.",
"",
`Sign in with GitHub to claim the spot you reserved with ${row.email}:`,
"",
`${env.APP_URL}/login`,
"",
"Your early signup is flagged for priority review.",
"",
"— The Tripwire team",
].join("\n"),
},
{ idempotencyKey: `waitlist-migration:${row.email.toLowerCase()}` }
)
sent++
} catch (err) {
failed++
console.error(`[waitlist-migration] failed for ${row.email}:`, err)
}
}

console.log(`[waitlist-migration] done — ${sent} sent, ${failed} failed.`)
}

main()
.then(() => process.exit(0))
.catch((err) => {
console.error("[waitlist-migration] failed:", err)
process.exit(1)
})
57 changes: 57 additions & 0 deletions apps/web/src/components/layout/app/shell/access-gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { ReactNode } from "react"
import { useQuery } from "@tanstack/react-query"
import { useTRPC } from "#/integrations/trpc/react"
import { AccessPendingScreen } from "#/components/layout/auth/access-pending-screen"

function GateSpinner() {
return (
<div className="flex h-screen w-full items-center justify-center bg-tw-bg">
<div className="size-5 animate-spin rounded-full border-2 border-tw-accent border-t-transparent" />
</div>
)
}

/**
* Access boundary. For a gated, not-yet-approved user this REPLACES the app
* shell with the waitlist screen — the dashboard (and its repo/workspace
* queries) never mount, so there's no flash of "Install Tripwire…" before a
* redirect. Sits under `AuthProvider` (needs the session) and above the
* workspace/chat providers.
*
* The decision comes from `auth.me.gateEnabled` — the SAME server-side flag
* evaluation the API gate (`approvedProcedure`) uses, including the env
* fallback — so the client can never disagree with what the server enforces.
* The server remains the real boundary; this only prevents the flash.
*/
export function AccessGate({ children }: { children: ReactNode }) {
const trpc = useTRPC()
const me = useQuery(
trpc.auth.me.queryOptions(undefined, { staleTime: 30_000 })
)

const data = me.data

// Approved users are never gated — render the shell immediately.
if (data?.accessStatus === "approved") return <>{children}</>

// Still resolving — hold on a neutral loader, never the shell.
if (me.isLoading) return <GateSpinner />

// Not logged in (AuthProvider handles the redirect to /login).
if (!data) return <>{children}</>

// Gate on (status is already known non-approved here) → replace the
// dashboard with the waitlist screen.
if (data.gateEnabled) {
return (
<AccessPendingScreen
email={data.email}
image={data.image}
status={data.accessStatus}
/>
)
}

// Gate off → render the shell as normal.
return <>{children}</>
}
15 changes: 10 additions & 5 deletions apps/web/src/components/layout/app/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,22 @@ import { InstallGitHubPrompt } from "#/components/layout/app/shell/install-githu
import { AskSidePanel } from "#/components/layout/app/chat/ask-side-panel"
import { WorkspaceProvider, useWorkspace } from "#/providers/workspace-context"
import { ChatProvider, useAIChat } from "#/providers/chat-context"
import { AccessGate } from "#/components/layout/app/shell/access-gate"
import { useRequestNotifications } from "#/hooks/use-request-notifications"
import { useOnboardingRedirect } from "#/hooks/use-onboarding-redirect"

export function AppShell() {
return (
<AuthProvider>
<WorkspaceProvider>
<ChatProvider>
<AppShellInner />
</ChatProvider>
</WorkspaceProvider>
{/* Access gate first: a gated, not-yet-approved user gets the waitlist
screen INSTEAD of the shell, so the dashboard never mounts. */}
<AccessGate>
<WorkspaceProvider>
<ChatProvider>
<AppShellInner />
</ChatProvider>
</WorkspaceProvider>
</AccessGate>
</AuthProvider>
)
}
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/components/layout/auth/access-pending-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { authClient } from "@tripwire/auth/client"
import { TripwireLogo } from "@tripwire/ui/icons/tripwire-logo"
import type { AccessStatus } from "@tripwire/db"

/**
* Minimal full-screen waitlist / access-status screen. Shared by the `/queue`
* route and the in-shell `AccessGate` boundary so both render identically.
*/
export function AccessPendingScreen({
email,
image,
status,
}: {
email?: string | null
image?: string | null
status: AccessStatus
}) {
const rejected = status === "rejected"

return (
<div className="flex min-h-screen w-full items-center justify-center bg-tw-bg px-6 antialiased">
<div className="flex w-full max-w-[360px] flex-col items-center gap-7 text-center">
<TripwireLogo className="size-7 text-tw-text-primary" />

<div className="flex flex-col gap-2.5">
<h1 className="text-[17px] font-semibold text-tw-text-primary">
{rejected ? "Not this time" : "You're on the waitlist"}
</h1>
<p className="text-[13px] leading-relaxed text-tw-text-secondary">
{rejected
? "Your access request wasn't approved. Thanks for your interest in Tripwire — feel free to check back as we open up more broadly."
: "You applied with GitHub — you're in line for the closed beta. We review requests manually and will email you the moment you're approved."}
</p>
</div>

<div className="flex items-center gap-2 text-[12px] text-tw-text-muted">
{image ? (
<img src={image} alt="" className="size-5 rounded-full" />
) : null}
{email ? <span>{email}</span> : null}
<span className="text-tw-text-tertiary">·</span>
<button
type="button"
onClick={() => authClient.signOut()}
className="text-tw-text-tertiary underline-offset-2 hover:text-tw-text-secondary hover:underline"
>
Sign out
</button>
</div>
</div>
</div>
)
}
18 changes: 9 additions & 9 deletions apps/web/src/components/layout/auth/login-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export function LoginPage() {
queryFn: async () => {
const { data } = await authClient.signIn.social({
provider: "github",
callbackURL: "/rules",
// New-user creation is blocked server-side; failures land back here
// so we can show the "sign-ups paused" note instead of a raw error.
// Both new and returning users land here; the access gate routes
// pending/rejected users to /queue and approved users onward.
callbackURL: "/home",
errorCallbackURL: "/login",
disableRedirect: true,
})
Expand All @@ -52,12 +52,11 @@ export function LoginPage() {
<div className="flex flex-col items-center gap-3">
{error ? (
<p className="max-w-xs text-center text-[13px] leading-relaxed text-tw-text-secondary">
We're not taking new sign-ups right now. If you already have access,
log in below.
Something went wrong signing you in. Try again below.
</p>
) : (
<span className="text-[14px] text-tw-text-secondary">
Already have access?
<span className="max-w-xs text-center text-[14px] text-tw-text-secondary">
Sign in with GitHub to request access
</span>
)}
<Button
Expand All @@ -67,11 +66,12 @@ export function LoginPage() {
size="sm"
className="border-[#CDCDCD] bg-white text-black hover:bg-white/90"
>
Log in
Continue with GitHub
</Button>
{error ? null : (
<p className="max-w-xs text-center text-[12px] leading-relaxed text-tw-text-muted">
New sign-ups are paused for now — check back soon.
New here? Access is manually approved — we'll email you once you're
in.
</p>
)}
</div>
Expand Down
Loading
Loading