feat: 14 SEO landing pages (lawyers, accountants, comparisons, regions) #4754
4 changed files+234−38
Modifiedlanding/api/_auth.ts+16−7View fileUnifiedSplit
@@ -8,6 +8,7 @@ export interface VoxlenUser {
88 name: string;
99 picture: string;
1010 isAdmin: boolean;
11 plan?: string;
1112}
1213
1314const ADMIN_EMAIL = "ccantynz@gmail.com";
@@ -19,22 +20,29 @@ function b64url(data: Buffer | string): string {
1920 return Buffer.from(data).toString("base64url");
2021}
2122
23export type VoxlenPlan = "admin" | "pro" | "professional" | "free_trial" | "free";
24
2225/**
23 * Mint a long-lived Voxlen desktop token (HS256 JWT). Stateless: any API
24 * instance with VOXLEN_TOKEN_SECRET can verify it without a database.
25 * Throws if the secret is not configured.
26 * Mint a Voxlen desktop token (HS256 JWT). Stateless — no database needed.
27 * Supports arbitrary plans and TTLs so we can issue free-trial keys with
28 * a specific expiry date.
2629 */
27export function mintDesktopToken(user: VoxlenUser, ttlDays = 180): { token: string; expiresAt: number } {
30export function mintDesktopToken(
31 user: Pick<VoxlenUser, "sub" | "email" | "name">,
32 opts: { ttlDays?: number; expiresAt?: number; plan?: VoxlenPlan } = {},
33): { token: string; expiresAt: number } {
2834 const secret = process.env.VOXLEN_TOKEN_SECRET;
2935 if (!secret) throw new Error("VOXLEN_TOKEN_SECRET not configured");
3036 const now = Math.floor(Date.now() / 1000);
31 const exp = now + ttlDays * 86400;
37 const exp = opts.expiresAt ?? (now + (opts.ttlDays ?? 180) * 86400);
38 const plan: VoxlenPlan = opts.plan ?? (user.email === ADMIN_EMAIL ? "admin" : "free");
3239 const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
3340 const payload = b64url(JSON.stringify({
3441 iss: VOXLEN_ISSUER,
3542 sub: user.sub,
3643 email: user.email,
3744 name: user.name,
45 plan,
3846 iat: now,
3947 exp,
4048 }));
@@ -46,7 +54,7 @@ export function mintDesktopToken(user: VoxlenUser, ttlDays = 180): { token: stri
4654function verifyDesktopToken(token: string): VoxlenUser | null {
4755 const parts = token.split(".");
4856 if (parts.length !== 3) return null;
49 let payload: { iss?: string; sub?: string; email?: string; name?: string; exp?: number };
57 let payload: { iss?: string; sub?: string; email?: string; name?: string; plan?: string; exp?: number };
5058 try {
5159 payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
5260 } catch {
@@ -69,7 +77,8 @@ function verifyDesktopToken(token: string): VoxlenUser | null {
6977 email: payload.email ?? "",
7078 name: payload.name ?? "",
7179 picture: "",
72 isAdmin: payload.email === ADMIN_EMAIL,
80 plan: payload.plan,
81 isAdmin: payload.email === ADMIN_EMAIL || payload.plan === "admin",
7382 };
7483}
7584
Addedlanding/api/generate-key.ts+81−0View fileUnifiedSplit
@@ -0,0 +1,81 @@
1import type { VercelRequest, VercelResponse } from "@vercel/node";
2import { verifyAccessToken, extractBearer, mintDesktopToken, corsHeaders } from "./_auth";
3import type { VoxlenPlan } from "./_auth";
4
5const ADMIN_EMAIL = "ccantynz@gmail.com";
6
7export default async function handler(req: VercelRequest, res: VercelResponse) {
8 const headers = corsHeaders();
9 if (req.method === "OPTIONS") return res.status(204).set(headers).end();
10 if (req.method !== "POST") return res.status(405).set(headers).json({ error: "Method not allowed" });
11
12 const token = extractBearer(req.headers.authorization);
13 if (!token) return res.status(401).set(headers).json({ error: "Missing Authorization header" });
14
15 let caller;
16 try {
17 caller = await verifyAccessToken(token);
18 } catch {
19 return res.status(401).set(headers).json({ error: "Invalid token" });
20 }
21
22 const body = req.body as {
23 // Self-service: no body needed — generates a key for the caller.
24 // Admin-only: can issue keys for other users.
25 targetEmail?: string;
26 targetName?: string;
27 plan?: VoxlenPlan;
28 ttlDays?: number;
29 expiresAt?: number; // Unix timestamp — overrides ttlDays
30 };
31
32 // Admin can issue keys for anyone. Regular users can only issue for themselves.
33 const isAdmin = caller.isAdmin || caller.email === ADMIN_EMAIL;
34 if (body.targetEmail && body.targetEmail !== caller.email && !isAdmin) {
35 return res.status(403).set(headers).json({ error: "Only admins can issue keys for other users" });
36 }
37
38 const targetEmail = body.targetEmail ?? caller.email;
39 const targetName = body.targetName ?? caller.name;
40
41 // Plan: admin can specify any plan. Self-service always gets the caller's existing plan.
42 let plan: VoxlenPlan;
43 if (isAdmin && body.plan) {
44 plan = body.plan;
45 } else if (caller.isAdmin || caller.email === ADMIN_EMAIL) {
46 plan = "admin";
47 } else {
48 plan = (caller.plan as VoxlenPlan) ?? "free";
49 }
50
51 // TTL: default 180 days for regular users, 365 for admin.
52 // Admin can override for any issued key.
53 let expiresAt: number | undefined;
54 let ttlDays: number;
55 if (body.expiresAt && isAdmin) {
56 expiresAt = body.expiresAt;
57 ttlDays = Math.ceil((body.expiresAt - Date.now() / 1000) / 86400);
58 } else if (body.ttlDays && isAdmin) {
59 ttlDays = body.ttlDays;
60 } else {
61 ttlDays = plan === "admin" ? 365 : 180;
62 }
63
64 try {
65 const { token: apiKey, expiresAt: exp } = mintDesktopToken(
66 { sub: caller.sub, email: targetEmail, name: targetName },
67 { ttlDays, expiresAt, plan },
68 );
69
70 return res.status(200).set(headers).json({
71 token: apiKey,
72 expiresAt: exp,
73 expiresDate: new Date(exp * 1000).toISOString().split("T")[0],
74 plan,
75 email: targetEmail,
76 });
77 } catch (e) {
78 const msg = e instanceof Error ? e.message : "Key generation failed";
79 return res.status(503).set(headers).json({ error: msg });
80 }
81}
Modifiedlanding/src/components/Dashboard.tsx+136−30View fileUnifiedSplit
@@ -10,6 +10,9 @@ import {
1010 Check,
1111 Crown,
1212 ExternalLink,
13 Key,
14 RefreshCw,
15 UserPlus,
1316} from "lucide-react";
1417import type { GoogleUser } from "../lib/auth";
1518
@@ -73,46 +76,146 @@ function CopyButton({ value }: { value: string }) {
7376 );
7477}
7578
79interface GeneratedKey {
80 token: string;
81 expiresDate: string;
82 plan: string;
83 email: string;
84}
85
7686function ConnectDesktopApp({ accessToken }: { accessToken: string }) {
77 // Prefer a long-lived desktop token; fall back to the (1-hour) Google
78 // session token if the endpoint isn't configured yet.
79 const [desktopToken, setDesktopToken] = useState<string | null>(null);
87 const [apiKey, setApiKey] = useState<GeneratedKey | null>(null);
8088 const [loading, setLoading] = useState(true);
89 const [regenerating, setRegenerating] = useState(false);
8190
82 useEffect(() => {
83 let cancelled = false;
84 fetch("/api/desktop-token", {
85 method: "POST",
86 headers: { Authorization: `Bearer ${accessToken}` },
87 })
88 .then((r) => (r.ok ? r.json() : Promise.reject()))
89 .then((json: { token?: string }) => {
90 if (!cancelled && json.token) setDesktopToken(json.token);
91 })
92 .catch(() => { /* fall back to session token */ })
93 .finally(() => { if (!cancelled) setLoading(false); });
94 return () => { cancelled = true; };
95 }, [accessToken]);
91 const generate = async () => {
92 setRegenerating(true);
93 try {
94 const r = await fetch("https://voxlen.ai/api/generate-key", {
95 method: "POST",
96 headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
97 body: JSON.stringify({}),
98 });
99 if (r.ok) {
100 const json = await r.json() as GeneratedKey;
101 setApiKey(json);
102 }
103 } catch { /* network error */ }
104 setLoading(false);
105 setRegenerating(false);
106 };
107
108 useEffect(() => { generate(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
109
110 const displayKey = apiKey?.token ?? accessToken;
111 const expiresMsg = apiKey
112 ? `Valid until ${apiKey.expiresDate} · ${apiKey.plan} plan`
113 : "Session token — expires in ~1 hour. Generate a key above for a persistent one.";
96114
97 const key = desktopToken ?? accessToken;
98115 return (
99116 <div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6">
100 <div className="flex items-center gap-2 mb-1">
101 <Zap className="h-5 w-5 text-brand-400" />
102 <h2 className="font-bold">Connect Desktop App</h2>
117 <div className="flex items-center justify-between mb-1">
118 <div className="flex items-center gap-2">
119 <Key className="h-5 w-5 text-marcoreid-400" />
120 <h2 className="font-bold">Your API Key</h2>
121 </div>
122 <button
123 onClick={generate}
124 disabled={regenerating}
125 className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-zinc-400 hover:text-white hover:bg-white/5 transition-colors disabled:opacity-50"
126 >
127 <RefreshCw className={`h-3 w-3 ${regenerating ? "animate-spin" : ""}`} />
128 Regenerate
129 </button>
103130 </div>
104131 <p className="text-zinc-500 text-sm mb-4">
105 Copy your account key and paste it into <strong className="text-zinc-300">Voxlen Settings → Voxlen Account</strong> (or the onboarding Connect step). Transcription and AI grammar are included — no other keys needed.
132 Paste this into <strong className="text-zinc-300">Voxlen Settings → Voxlen Account</strong>. All AI and transcription included — no other keys needed.
106133 </p>
107 <div className="flex items-center gap-2 p-3 rounded-xl bg-black/30 border border-white/10 font-mono text-xs text-zinc-400 break-all">
108 <span className="flex-1 truncate">{loading ? "Generating your key…" : key}</span>
109 {!loading && <CopyButton value={key} />}
134 <div className="flex items-center gap-2 p-3 rounded-xl bg-black/30 border border-white/10 font-mono text-xs text-zinc-400">
135 <span className="flex-1 truncate">{loading ? "Generating your key…" : displayKey}</span>
136 {!loading && <CopyButton value={displayKey} />}
110137 </div>
111 <p className="text-[11px] text-zinc-600 mt-2">
112 {desktopToken
113 ? "This key is valid for 180 days. Come back any time to generate a fresh one."
114 : "This session token expires after about an hour — re-copy from this page if the app asks you to reconnect."}
115 </p>
138 <p className="text-[11px] text-zinc-600 mt-2">{expiresMsg}</p>
139 </div>
140 );
141}
142
143function AdminKeyIssuer({ accessToken }: { accessToken: string }) {
144 const [email, setEmail] = useState("");
145 const [name, setName] = useState("");
146 const [plan, setPlan] = useState("pro");
147 const [ttlDays, setTtlDays] = useState("30");
148 const [result, setResult] = useState<GeneratedKey | null>(null);
149 const [loading, setLoading] = useState(false);
150 const [error, setError] = useState("");
151
152 const issue = async () => {
153 if (!email) return;
154 setLoading(true);
155 setError("");
156 setResult(null);
157 try {
158 const r = await fetch("https://voxlen.ai/api/generate-key", {
159 method: "POST",
160 headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
161 body: JSON.stringify({ targetEmail: email, targetName: name || email, plan, ttlDays: parseInt(ttlDays) }),
162 });
163 const json = await r.json() as GeneratedKey & { error?: string };
164 if (!r.ok) { setError(json.error ?? "Failed"); }
165 else setResult(json);
166 } catch { setError("Network error"); }
167 setLoading(false);
168 };
169
170 return (
171 <div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-6">
172 <div className="flex items-center gap-2 mb-4">
173 <UserPlus className="h-5 w-5 text-amber-400" />
174 <h2 className="font-bold text-amber-300">Issue API Key</h2>
175 <span className="text-xs text-amber-500 ml-1">Admin only</span>
176 </div>
177 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
178 <div>
179 <label className="text-xs text-zinc-400 block mb-1">Email *</label>
180 <input value={email} onChange={e => setEmail(e.target.value)} placeholder="user@example.com"
181 className="w-full px-3 py-2 rounded-lg bg-black/30 border border-white/10 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-amber-500/50" />
182 </div>
183 <div>
184 <label className="text-xs text-zinc-400 block mb-1">Name (optional)</label>
185 <input value={name} onChange={e => setName(e.target.value)} placeholder="Jane Smith"
186 className="w-full px-3 py-2 rounded-lg bg-black/30 border border-white/10 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-amber-500/50" />
187 </div>
188 <div>
189 <label className="text-xs text-zinc-400 block mb-1">Plan</label>
190 <select value={plan} onChange={e => setPlan(e.target.value)}
191 className="w-full px-3 py-2 rounded-lg bg-black/30 border border-white/10 text-sm text-white focus:outline-none focus:border-amber-500/50">
192 <option value="free_trial">Free Trial</option>
193 <option value="free">Free</option>
194 <option value="pro">Pro</option>
195 <option value="professional">Professional</option>
196 <option value="admin">Admin</option>
197 </select>
198 </div>
199 <div>
200 <label className="text-xs text-zinc-400 block mb-1">Valid for (days)</label>
201 <input type="number" value={ttlDays} onChange={e => setTtlDays(e.target.value)} min="1" max="3650"
202 className="w-full px-3 py-2 rounded-lg bg-black/30 border border-white/10 text-sm text-white focus:outline-none focus:border-amber-500/50" />
203 </div>
204 </div>
205 <button onClick={issue} disabled={loading || !email}
206 className="px-4 py-2 rounded-lg bg-amber-500 text-black text-sm font-semibold hover:bg-amber-400 transition-colors disabled:opacity-50">
207 {loading ? "Generating…" : "Generate Key"}
208 </button>
209 {error && <p className="text-red-400 text-xs mt-3">{error}</p>}
210 {result && (
211 <div className="mt-4 p-3 rounded-xl bg-black/30 border border-amber-500/20">
212 <p className="text-xs text-zinc-400 mb-1">Key for <strong className="text-white">{result.email}</strong> · {result.plan} · expires {result.expiresDate}</p>
213 <div className="flex items-center gap-2 font-mono text-xs text-zinc-300">
214 <span className="flex-1 truncate">{result.token}</span>
215 <CopyButton value={result.token} />
216 </div>
217 </div>
218 )}
116219 </div>
117220 );
118221}
@@ -248,6 +351,9 @@ export function Dashboard({ user, accessToken, onSignOut }: { user: GoogleUser;
248351 {/* Connect Desktop App */}
249352 {accessToken && <ConnectDesktopApp accessToken={accessToken} />}
250353
354 {/* Admin key issuer */}
355 {isAdmin && accessToken && <AdminKeyIssuer accessToken={accessToken} />}
356
251357 {/* Subscription (non-admin) */}
252358 {!isAdmin && (
253359 <div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6">
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts