Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1347de2
Add hosted plan tiers + entitlements config
evangauer Jun 7, 2026
ab2e0f0
Add hosted subscription fields to practices
evangauer Jun 7, 2026
4aa3d53
Add requireFeature plan gating + apply to agent, reports, API
evangauer Jun 7, 2026
103bb35
Add subscription router + Stripe billing portal
evangauer Jun 7, 2026
5546c72
Add Stripe subscription webhook
evangauer Jun 7, 2026
3d499df
Provision a 14-day full-featured trial on hosted signup
evangauer Jun 7, 2026
54b557e
Add Plan & Billing settings tab
evangauer Jun 7, 2026
9a533c8
Add internal platform-admin dashboard
evangauer Jun 7, 2026
0927013
Run authenticated requests in a tenant DB context (RLS plumbing)
evangauer Jun 7, 2026
2355824
Add Postgres Row-Level Security migration + live isolation test
evangauer Jun 7, 2026
d26962a
Wire system context for auth/public/admin paths + RLS docs
evangauer Jun 7, 2026
1214e94
Re-tune plan model to one Cloud tier ($99/location) + feature parity
evangauer Jun 7, 2026
0967372
Add usage metering for SMS + AI agent runs
evangauer Jun 7, 2026
29c3d3e
Bill Cloud per location + show usage in billing UI
evangauer Jun 7, 2026
5b31fc4
Wire RLS context into all non-tRPC routes (completes task #38)
evangauer Jun 7, 2026
b3ea02c
Add email verification + password reset (hosted)
evangauer Jun 7, 2026
0e58b7d
Add demo-data seeding + first-run onboarding wizard
evangauer Jun 7, 2026
751955f
chore: gitignore local MCP config (.mcp.json)
evangauer Jun 8, 2026
9eacf1b
Remove hardcoded credential from RLS script (env-driven role)
evangauer Jun 8, 2026
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
19 changes: 18 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,27 @@ TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_PHONE_NUMBER=

# Stripe
# Stripe (client invoicing — client portal payments)
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

# Hosted SaaS subscriptions (managed service only — leave HOSTED_BILLING_ENABLED
# unset for self-host so the OSS edition runs fully unlocked). When enabled,
# plan tiers are enforced and these must be set.
HOSTED_BILLING_ENABLED=
# Cloud plan: $99/mo recurring Price, charged per location (Stripe quantity).
STRIPE_PRICE_CLOUD=
# Metered overage Prices for usage beyond the included monthly allowances.
STRIPE_PRICE_SMS_OVERAGE=
STRIPE_PRICE_AI_OVERAGE=
# Separate webhook endpoint secret for customer.subscription.* events.
STRIPE_SUBSCRIPTION_WEBHOOK_SECRET=
# Public base URL used to build Stripe success/return URLs (falls back to NEXTAUTH_URL).
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Comma-separated emails allowed into the cross-tenant platform admin dashboard
# (/admin). OpenVPM operators only — separate from a practice's own admin role.
PLATFORM_ADMIN_EMAILS=

# Cron job authentication
CRON_SECRET=

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ docs/screenshots/audit/
test-results/
CAVSG_AI_Innovator_Survey_COMPLETED.docx
package-lock.json

# Local MCP server config (not for the public repo)
.mcp.json
70 changes: 70 additions & 0 deletions apps/web/app/(auth)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import { useState } from "react";
import Link from "next/link";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";

export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);

const request = trpc.auth.requestPasswordReset.useMutation({
onSuccess: () => setSent(true),
onError: (err) => toast.error(err.message),
});

return (
<div className="flex min-h-screen items-center justify-center bg-surface">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl font-bold text-foreground">OpenVPM</h1>
<p className="mt-1 text-sm text-muted-foreground">Reset your password</p>
</div>

{sent ? (
<p className="text-center text-sm text-muted-foreground">
If an account exists for <strong>{email}</strong>, we&apos;ve sent a reset link.
Check your inbox.
</p>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
request.mutate({ email });
}}
className="space-y-4"
>
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-foreground">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="you@clinic.com"
/>
</div>
<button
type="submit"
disabled={request.isPending}
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{request.isPending ? "Sending…" : "Send reset link"}
</button>
</form>
)}

<p className="mt-4 text-center text-sm text-muted-foreground">
<Link href="/login" className="text-primary hover:underline">
Back to sign in
</Link>
</p>
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions apps/web/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ export default function LoginPage() {
</form>

<p className="mt-4 text-center text-sm text-muted-foreground">
<Link href="/forgot-password" className="text-primary hover:underline">
Forgot your password?
</Link>
</p>
<p className="mt-2 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/register" className="text-primary hover:underline">
Register your practice
Expand Down
33 changes: 30 additions & 3 deletions apps/web/app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,23 @@ export default function RegisterPage() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

const [verifySent, setVerifySent] = useState(false);

const registerMutation = trpc.auth.register.useMutation({
onSuccess: async () => {
onSuccess: async (data) => {
// Hosted: email verification required → show a "check your inbox" notice.
if (data?.verificationRequired) {
setVerifySent(true);
setLoading(false);
return;
}
// Self-host: auto-sign in after registration.
toast.success("Account created! Redirecting...");
// Auto-sign in after registration
const result = await signIn("credentials", {
email,
password,
redirect: false,
});

if (result?.ok) {
router.push("/");
router.refresh();
Expand All @@ -38,6 +45,23 @@ export default function RegisterPage() {
},
});

if (verifySent) {
return (
<div className="flex min-h-screen items-center justify-center bg-surface">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-8 text-center">
<h1 className="font-heading text-2xl font-bold text-foreground">Check your email</h1>
<p className="mt-3 text-sm text-muted-foreground">
We sent a verification link to <strong>{email}</strong>. Click it to activate
your account and start your 14-day free trial.
</p>
<Link href="/login" className="mt-6 inline-block text-sm text-primary hover:underline">
Back to sign in
</Link>
</div>
</div>
);
}

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
Expand All @@ -55,6 +79,9 @@ export default function RegisterPage() {
<p className="mt-1 text-sm text-muted-foreground">
Register your practice
</p>
<p className="mt-1 text-xs text-muted-foreground">
14-day free trial · no credit card required
</p>
</div>

<form onSubmit={handleSubmit} className="space-y-4">
Expand Down
85 changes: 85 additions & 0 deletions apps/web/app/(auth)/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use client";

import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";

function ResetPasswordInner() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const [password, setPassword] = useState("");
const [done, setDone] = useState(false);

const reset = trpc.auth.resetPassword.useMutation({
onSuccess: () => setDone(true),
onError: (err) => toast.error(err.message),
});

return (
<div className="flex min-h-screen items-center justify-center bg-surface">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl font-bold text-foreground">OpenVPM</h1>
<p className="mt-1 text-sm text-muted-foreground">Choose a new password</p>
</div>

{done ? (
<div className="text-center">
<p className="text-sm text-foreground">Your password has been reset.</p>
<Link
href="/login"
className="mt-6 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Sign in
</Link>
</div>
) : !token ? (
<p className="text-center text-sm text-destructive">
This reset link is invalid. Request a new one from the sign-in page.
</p>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
reset.mutate({ token, password });
}}
className="space-y-4"
>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-foreground">
New password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="At least 8 characters"
/>
</div>
<button
type="submit"
disabled={reset.isPending}
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{reset.isPending ? "Resetting…" : "Reset password"}
</button>
</form>
)}
</div>
</div>
);
}

export default function ResetPasswordPage() {
return (
<Suspense fallback={null}>
<ResetPasswordInner />
</Suspense>
);
}
71 changes: 71 additions & 0 deletions apps/web/app/(auth)/verify-email/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";

import { Suspense, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { trpc } from "@/lib/trpc";

function VerifyEmailInner() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const [status, setStatus] = useState<"verifying" | "ok" | "error">("verifying");
const ran = useRef(false);

const verify = trpc.auth.verifyEmail.useMutation({
onSuccess: () => setStatus("ok"),
onError: () => setStatus("error"),
});

useEffect(() => {
if (ran.current) return;
ran.current = true;
if (!token) {
setStatus("error");
return;
}
verify.mutate({ token });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);

return (
<div className="flex min-h-screen items-center justify-center bg-surface">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-8 text-center">
<h1 className="font-heading text-2xl font-bold text-foreground">OpenVPM</h1>
{status === "verifying" && (
<p className="mt-3 text-sm text-muted-foreground">Verifying your email…</p>
)}
{status === "ok" && (
<>
<p className="mt-3 text-sm text-foreground">
Your email is verified. You can now sign in and start your free trial.
</p>
<Link
href="/login"
className="mt-6 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Sign in
</Link>
</>
)}
{status === "error" && (
<>
<p className="mt-3 text-sm text-destructive">
This verification link is invalid or has expired.
</p>
<Link href="/login" className="mt-6 inline-block text-sm text-primary hover:underline">
Back to sign in
</Link>
</>
)}
</div>
</div>
);
}

export default function VerifyEmailPage() {
return (
<Suspense fallback={null}>
<VerifyEmailInner />
</Suspense>
);
}
Loading
Loading