feat(marketing): add contact form submission and testing setup
- Add contact API endpoint for demo requests and sales enquiries - Implement functional contact forms on Demo and Pricing pages with honeypot spam protection - Update footer layout: remove Company section, add contact email - Update self-hosted FAQ to mention open source with GitHub links - Add vitest and testing-library dependencies - Add tests for MarketingLayout and PricingPage components - Remove placeholder external-auth test file
This commit is contained in:
+3
-1
@@ -25,4 +25,6 @@ dist-ssr
|
|||||||
|
|
||||||
*.env
|
*.env
|
||||||
|
|
||||||
.swarm/
|
.swarm/
|
||||||
|
|
||||||
|
SWARM_PLAN*
|
||||||
Generated
+1433
-13
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -46,6 +46,7 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jsdom": "^27.0.1",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -64,10 +65,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||||
|
"@vitest/ui": "^3.2.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.32.0",
|
"eslint": "^9.32.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
@@ -78,6 +82,7 @@
|
|||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0",
|
||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ return (
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="border-t bg-muted/30">
|
<footer className="border-t bg-muted/30">
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-8">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<div className="col-span-2 lg:col-span-1">
|
<div className="col-span-2 lg:col-span-1">
|
||||||
<Link to="/" className="flex items-center gap-2.5">
|
<Link to="/" className="flex items-center gap-2.5">
|
||||||
@@ -135,6 +135,9 @@ return (
|
|||||||
<p className="mt-4 text-sm text-muted-foreground max-w-xs">
|
<p className="mt-4 text-sm text-muted-foreground max-w-xs">
|
||||||
Enterprise identity and access management. Secure by design, simple by choice.
|
Enterprise identity and access management. Secure by design, simple by choice.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
Contact us: <a href="mailto:info@secuird.tech" className="text-foreground hover:underline transition-colors">info@secuird.tech</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product */}
|
{/* Product */}
|
||||||
@@ -160,17 +163,6 @@ return (
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">Company</h3>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">About</a></li>
|
|
||||||
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Blog</a></li>
|
|
||||||
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Careers</a></li>
|
|
||||||
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Contact</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legal */}
|
{/* Legal */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">Legal</h3>
|
<h3 className="text-sm font-semibold text-foreground mb-3">Legal</h3>
|
||||||
|
|||||||
@@ -1223,6 +1223,22 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
contact: {
|
||||||
|
submit: (data: {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
company?: string;
|
||||||
|
enquiry_type: string;
|
||||||
|
message?: string;
|
||||||
|
interest_area?: string;
|
||||||
|
_hp?: string;
|
||||||
|
}) =>
|
||||||
|
request<void>('/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}, false),
|
||||||
|
},
|
||||||
|
|
||||||
ssh: {
|
ssh: {
|
||||||
// List all SSH keys for the current user
|
// List all SSH keys for the current user
|
||||||
listKeys: (requestConfig?: RequestConfig) =>
|
listKeys: (requestConfig?: RequestConfig) =>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default function OIDCLoginPage() {
|
|||||||
setErrorMsg(err.message);
|
setErrorMsg(err.message);
|
||||||
setStep("error");
|
setStep("error");
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [oidcSessionId]);
|
}, [oidcSessionId]);
|
||||||
|
|
||||||
// ── Determine step once both context and auth state are ready ───────────────
|
// ── Determine step once both context and auth state are ready ───────────────
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Users,
|
Users,
|
||||||
Lock,
|
Lock,
|
||||||
Terminal,
|
Terminal,
|
||||||
Shield,
|
Shield,
|
||||||
MonitorPlay,
|
MonitorPlay,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const demos = [
|
const demos = [
|
||||||
{
|
{
|
||||||
@@ -55,6 +58,41 @@ icon: MonitorPlay,
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function DemoPage() {
|
export default function DemoPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [company, setCompany] = useState("");
|
||||||
|
const [interestArea, setInterestArea] = useState("SSO / Social Login");
|
||||||
|
const [hp, setHp] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setSuccess(false);
|
||||||
|
try {
|
||||||
|
await api.contact.submit({
|
||||||
|
email,
|
||||||
|
company,
|
||||||
|
enquiry_type: "demo_request",
|
||||||
|
interest_area: interestArea,
|
||||||
|
_hp: hp,
|
||||||
|
});
|
||||||
|
setSuccess(true);
|
||||||
|
setEmail("");
|
||||||
|
setCompany("");
|
||||||
|
setInterestArea("SSO / Social Login");
|
||||||
|
setHp("");
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Submission failed",
|
||||||
|
description: err instanceof Error ? err.message : "Something went wrong.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
@@ -187,38 +225,67 @@ return (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg p-6 border">
|
<div className="bg-card rounded-lg p-6 border">
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-4">Request Demo</h3>
|
{success ? (
|
||||||
<form className="space-y-4">
|
<div className="text-center py-8">
|
||||||
<div>
|
<div className="h-12 w-12 rounded-full bg-accent/10 flex items-center justify-center mx-auto mb-4">
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">Work Email</label>
|
<Play className="h-6 w-6 text-accent" />
|
||||||
<input
|
</div>
|
||||||
type="email"
|
<h3 className="text-lg font-semibold text-foreground mb-2">Request Received!</h3>
|
||||||
className="w-full px-3 py-2 bg-background border border-input rounded-md text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
<p className="text-muted-foreground text-sm">Thanks! We'll be in touch shortly.</p>
|
||||||
placeholder="you@company.com"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">Company</label>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Honeypot — hidden from real users */}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full px-3 py-2 bg-background border border-input rounded-md text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
name="_hp"
|
||||||
placeholder="Your company name"
|
value={hp}
|
||||||
|
onChange={(e) => setHp(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label className="block text-sm font-medium text-foreground mb-1">Work Email</label>
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">What are you looking for?</label>
|
<input
|
||||||
<select className="w-full px-3 py-2 bg-background border border-input rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-ring">
|
type="email"
|
||||||
<option>SSO / Social Login</option>
|
value={email}
|
||||||
<option>SSH Certificate Management</option>
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
<option>MFA / Security</option>
|
required
|
||||||
<option>OIDC Provider</option>
|
className="w-full px-3 py-2 bg-background border border-input rounded-md text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
<option>Full IAM Solution</option>
|
placeholder="you@company.com"
|
||||||
</select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full">
|
<div>
|
||||||
Request Demo
|
<label className="block text-sm font-medium text-foreground mb-1">Company</label>
|
||||||
</Button>
|
<input
|
||||||
</form>
|
type="text"
|
||||||
|
value={company}
|
||||||
|
onChange={(e) => setCompany(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-background border border-input rounded-md text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="Your company name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">What are you looking for?</label>
|
||||||
|
<select
|
||||||
|
value={interestArea}
|
||||||
|
onChange={(e) => setInterestArea(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-background border border-input rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option>SSO / Social Login</option>
|
||||||
|
<option>SSH Certificate Management</option>
|
||||||
|
<option>MFA / Security</option>
|
||||||
|
<option>OIDC Provider</option>
|
||||||
|
<option>Full IAM Solution</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Sending..." : "Request Demo"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Users,
|
Users,
|
||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
Zap,
|
Zap,
|
||||||
Building2,
|
Building2,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const plans = [
|
const plans = [
|
||||||
{
|
{
|
||||||
@@ -97,7 +100,7 @@ answer: "Yes! We offer 50% off for qualified startups and non-profit organizatio
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Is there a self-hosted option?",
|
question: "Is there a self-hosted option?",
|
||||||
answer: "Yes, our Enterprise plan includes self-hosted deployment options. This is ideal for organizations with strict data residency requirements.",
|
answer: <>Yes, we are open source! You can find the UI at <a href="https://github.com/CoryHawkless/gatehouse-ui" target="_blank" rel="noopener noreferrer" className="text-foreground hover:underline transition-colors">https://github.com/CoryHawkless/gatehouse-ui</a> and the API at <a href="https://github.com/CoryHawkless/gatehouse-api" target="_blank" rel="noopener noreferrer" className="text-foreground hover:underline transition-colors">https://github.com/CoryHawkless/gatehouse-api</a>.</>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "What payment methods do you accept?",
|
question: "What payment methods do you accept?",
|
||||||
@@ -106,6 +109,46 @@ answer: "We accept all major credit cards, ACH transfers (US), and wire transfer
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [company, setCompany] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [hp, setHp] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
async function handleContactSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setSuccess(false);
|
||||||
|
try {
|
||||||
|
await api.contact.submit({
|
||||||
|
email,
|
||||||
|
company,
|
||||||
|
enquiry_type: "sales_enquiry",
|
||||||
|
message,
|
||||||
|
_hp: hp,
|
||||||
|
});
|
||||||
|
setSuccess(true);
|
||||||
|
setEmail("");
|
||||||
|
setCompany("");
|
||||||
|
setMessage("");
|
||||||
|
setHp("");
|
||||||
|
setTimeout(() => {
|
||||||
|
setSuccess(false);
|
||||||
|
setContactOpen(false);
|
||||||
|
}, 3000);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Submission failed",
|
||||||
|
description: err instanceof Error ? err.message : "Something went wrong.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
@@ -233,10 +276,83 @@ return (
|
|||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Talk to our team about your requirements
|
Talk to our team about your requirements
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" className="gap-2">
|
{contactOpen ? (
|
||||||
Contact Sales
|
<div className="bg-card rounded-lg p-4 border text-left w-full max-w-sm">
|
||||||
<ArrowRight className="h-4 w-4" />
|
{success ? (
|
||||||
</Button>
|
<div className="text-center py-4">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-accent/10 flex items-center justify-center mx-auto mb-3">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Request Sent!</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Thanks! Our sales team will reach out soon.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleContactSubmit} className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="_hp"
|
||||||
|
value={hp}
|
||||||
|
onChange={(e) => setHp(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Work email"
|
||||||
|
className="w-full px-3 py-2 bg-background border border-input rounded-md text-foreground placeholder:text-muted-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={company}
|
||||||
|
onChange={(e) => setCompany(e.target.value)}
|
||||||
|
placeholder="Company"
|
||||||
|
className="w-full px-3 py-2 bg-background border border-input rounded-md text-foreground placeholder:text-muted-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="How can we help?"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 bg-background border border-input rounded-md text-foreground placeholder:text-muted-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Sending..." : "Send"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setContactOpen(false); setSuccess(false); }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" className="gap-2" onClick={() => setContactOpen(true)}>
|
||||||
|
Contact Sales
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import MarketingLayout from '../src/components/layouts/MarketingLayout';
|
||||||
|
import { expect, test, describe } from 'bun:test';
|
||||||
|
|
||||||
|
describe('MarketingLayout', () => {
|
||||||
|
test('renders children correctly', () => {
|
||||||
|
render(<MarketingLayout><div>Test Child</div></MarketingLayout>);
|
||||||
|
expect(screen.getByText('Test Child')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes "Company" section from footer', () => {
|
||||||
|
render(<MarketingLayout><div>Test Child</div></MarketingLayout>);
|
||||||
|
expect(screen.queryByText('Company')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds contact email info@secuird.tech with mailto: link in Brand section', () => {
|
||||||
|
render(<MarketingLayout><div>Test Child</div></MarketingLayout>);
|
||||||
|
const emailLink = screen.getByText('info@secuird.tech');
|
||||||
|
expect(emailLink).toBeDefined();
|
||||||
|
expect(emailLink.closest('a')).toHaveAttribute('href', 'mailto:info@secuird.tech');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adjusts grid from 5-col to 4-col layout', () => {
|
||||||
|
render(<MarketingLayout><div>Test Child</div></MarketingLayout>);
|
||||||
|
// This test assumes that the grid layout is reflected in the rendered HTML,
|
||||||
|
// for example, by a class name like 'grid-cols-4' or similar.
|
||||||
|
// You might need to adjust this based on the actual implementation.
|
||||||
|
const footer = screen.getByRole('contentinfo'); // Assuming footer has role 'contentinfo'
|
||||||
|
expect(footer).toHaveProperty('className'); // Check if className exists
|
||||||
|
expect(footer.className).toMatch(/grid-cols-4/);
|
||||||
|
expect(footer.className).not.toMatch(/grid-cols-5/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes "Company" section from footer', () => {
|
||||||
|
render(<BrowserRouter><MarketingLayout><div>Test Child</div></MarketingLayout></BrowserRouter>);
|
||||||
|
expect(screen.queryByText('Company')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds contact email info@secuird.tech with mailto: link in Brand section', () => {
|
||||||
|
render(<BrowserRouter><MarketingLayout><div>Test Child</div></MarketingLayout></BrowserRouter>);
|
||||||
|
const emailLink = screen.getByText('info@secuird.tech');
|
||||||
|
expect(emailLink).toBeDefined();
|
||||||
|
expect(emailLink.getAttribute('href')).toBe('mailto:info@secuird.tech');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adjusts grid from 5-col to 4-col layout', () => {
|
||||||
|
render(<BrowserRouter><MarketingLayout><div>Test Child</div></MarketingLayout></BrowserRouter>);
|
||||||
|
const footer = screen.getByRole('contentinfo');
|
||||||
|
expect(footer.className).toMatch(/grid-cols-4/);
|
||||||
|
expect(footer.className).not.toMatch(/grid-cols-5/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes "Company" section from footer', () => {
|
||||||
|
render(<BrowserRouter><MarketingLayout><div>Test Child</div></MarketingLayout></BrowserRouter>);
|
||||||
|
expect(screen.queryByText('Company')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds contact email info@secuird.tech with mailto: link in Brand section', () => {
|
||||||
|
render(<BrowserRouter><MarketingLayout><div>Test Child</div></MarketingLayout></BrowserRouter>);
|
||||||
|
const emailLink = screen.getByText('info@secuird.tech');
|
||||||
|
expect(emailLink).toBeDefined();
|
||||||
|
expect(emailLink.closest('a').getAttribute('href')).toBe('mailto:info@secuird.tech');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adjusts grid from 5-col to 4-col layout', () => {
|
||||||
|
render(<BrowserRouter><MarketingLayout><div>Test Child</div></MarketingLayout></BrowserRouter>);
|
||||||
|
// This test assumes that the grid layout is reflected in the rendered HTML,
|
||||||
|
// for example, by a class name like 'grid-cols-4' or similar.
|
||||||
|
// You might need to adjust this based on the actual implementation.
|
||||||
|
const footer = screen.getByRole('contentinfo'); // Assuming footer has role 'contentinfo'
|
||||||
|
expect(footer.className).toMatch(/grid-cols-4/);
|
||||||
|
expect(footer.className).not.toMatch(/grid-cols-5/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import PricingPage from '../src/pages/marketing/PricingPage';
|
||||||
|
import { expect, test, describe } from 'bun:test';
|
||||||
|
|
||||||
|
describe('PricingPage', () => {
|
||||||
|
test('Self-hosted FAQ answer updated to mention open source with GitHub links', () => {
|
||||||
|
render(<PricingPage />);
|
||||||
|
const selfHostedText = screen.getByText(/self-hosted/i);
|
||||||
|
expect(selfHostedText).toBeDefined();
|
||||||
|
|
||||||
|
const openSourceText = screen.getByText(/open source/i);
|
||||||
|
expect(openSourceText).toBeDefined();
|
||||||
|
|
||||||
|
const gatehouseUiLink = screen.getByRole('link', { name: 'gatehouse-ui' });
|
||||||
|
expect(gatehouseUiLink).toBeDefined();
|
||||||
|
expect(gatehouseUiLink).toHaveAttribute('href', 'https://github.com/gatehouse/gatehouse-ui');
|
||||||
|
|
||||||
|
const gatehouseApiLink = screen.getByRole('link', { name: 'gatehouse-api' });
|
||||||
|
expect(gatehouseApiLink).toBeDefined();
|
||||||
|
expect(gatehouseApiLink).toHaveAttribute('href', 'https://github.com/gatehouse/gatehouse-api');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
/**
|
|
||||||
* Frontend tests for external authentication components
|
|
||||||
* Tests Google OAuth login button, Linked Accounts page, and OAuth flows
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import React from 'react';
|
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
// Note: These are component tests for the external auth UI
|
|
||||||
// In a real project, you would use @testing-library/react and mock the API
|
|
||||||
|
|
||||||
describe('External Auth UI Components', () => {
|
|
||||||
describe('Google OAuth Button', () => {
|
|
||||||
it('should render Google login button with correct icon', () => {
|
|
||||||
// This test verifies the Google OAuth button is rendered
|
|
||||||
// In a real test, you would render the LoginPage and check for the button
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle Google login click event', async () => {
|
|
||||||
// Test that clicking Google login triggers the OAuth flow
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading state during authentication', () => {
|
|
||||||
// Test loading state while OAuth flow is in progress
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle authentication errors gracefully', async () => {
|
|
||||||
// Test error handling for OAuth failures
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Linked Accounts Page', () => {
|
|
||||||
it('should render linked accounts list', () => {
|
|
||||||
// Test that LinkedAccountsPage renders the list of linked accounts
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show connected status for linked providers', () => {
|
|
||||||
// Test that connected providers show "Connected" status
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show "Not connected" for unlinked providers', () => {
|
|
||||||
// Test that unlinked providers show "Not connected" status
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should disable unlink button when only one auth method', () => {
|
|
||||||
// Test that unlink is disabled when it's the last auth method
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle unlink confirmation', async () => {
|
|
||||||
// Test unlink confirmation dialog
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle unlink success', async () => {
|
|
||||||
// Test unlink success feedback
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle unlink error', async () => {
|
|
||||||
// Test unlink error handling
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show alert about external account limitations', () => {
|
|
||||||
// Test that the informational alert is displayed
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OAuth Flow States', () => {
|
|
||||||
it('should handle redirect from OAuth provider with code', async () => {
|
|
||||||
// Test handling callback with authorization code
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle OAuth error response', async () => {
|
|
||||||
// Test handling error from OAuth provider
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate state parameter matches', () => {
|
|
||||||
// Test state parameter validation for CSRF protection
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle expired state', async () => {
|
|
||||||
// Test handling of expired OAuth state
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Provider Configuration UI', () => {
|
|
||||||
it('should show provider configuration status', () => {
|
|
||||||
// Test that configured providers are marked as such
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow admin to configure provider', () => {
|
|
||||||
// Test provider configuration form for admins
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate required fields', () => {
|
|
||||||
// Test form validation for provider configuration
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle provider deletion', async () => {
|
|
||||||
// Test provider configuration deletion
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error Handling', () => {
|
|
||||||
it('should display OAuth error messages to user', async () => {
|
|
||||||
// Test error message display
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle network errors during OAuth flow', async () => {
|
|
||||||
// Test network error handling
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should provide retry options after failures', async () => {
|
|
||||||
// Test retry functionality after OAuth failures
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Security Considerations', () => {
|
|
||||||
it('should not expose tokens in URL', () => {
|
|
||||||
// Test that tokens are not exposed in URL fragments
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use state parameter for CSRF protection', () => {
|
|
||||||
// Test that state parameter is used
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should verify redirect URI matches configured value', () => {
|
|
||||||
// Test redirect URI validation
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('External Auth API Integration', () => {
|
|
||||||
describe('Provider List API', () => {
|
|
||||||
it('should fetch available providers', async () => {
|
|
||||||
// Test API call to fetch provider list
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should indicate configured vs unconfigured providers', async () => {
|
|
||||||
// Test provider configuration status in API response
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Link Account Flow API', () => {
|
|
||||||
it('should initiate link flow', async () => {
|
|
||||||
// Test API call to initiate linking
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return authorization URL and state', async () => {
|
|
||||||
// Test that API returns OAuth parameters
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should complete link flow', async () => {
|
|
||||||
// Test API call to complete linking
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Unlink Account API', () => {
|
|
||||||
it('should unlink provider account', async () => {
|
|
||||||
// Test API call to unlink provider
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prevent unlinking last method', async () => {
|
|
||||||
// Test error when trying to unlink last method
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Linked Accounts List API', () => {
|
|
||||||
it('should fetch linked accounts', async () => {
|
|
||||||
// Test API call to fetch linked accounts
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include provider details', async () => {
|
|
||||||
// Test that linked accounts include provider info
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OAuth Flow UX', () => {
|
|
||||||
describe('Loading States', () => {
|
|
||||||
it('should show spinner during OAuth redirect', () => {
|
|
||||||
// Test loading state during OAuth redirect
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show success message after linking', async () => {
|
|
||||||
// Test success feedback after account link
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show error toast on failure', async () => {
|
|
||||||
// Test error toast display
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Navigation', () => {
|
|
||||||
it('should redirect to correct page after OAuth login', async () => {
|
|
||||||
// Test navigation after successful OAuth login
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return to original page after linking', async () => {
|
|
||||||
// Test return to original page after account link
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle browser back button during OAuth', async () => {
|
|
||||||
// Test browser navigation handling during OAuth
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Accessibility', () => {
|
|
||||||
it('should have proper ARIA labels for provider buttons', () => {
|
|
||||||
// Test accessibility of OAuth buttons
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should announce OAuth errors to screen readers', async () => {
|
|
||||||
// Test error announcements for screen readers
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be keyboard navigable', () => {
|
|
||||||
// Test keyboard navigation support
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
Reference in New Issue
Block a user