metatroncubeswdev 075b79f6b2 Hosted Quorla: sign-up, Stripe billing, one workspace per agency
- Control centre (QUORLA_CONTROL=true on app.quorlasocial.com): public /signup → Stripe Checkout,
  verified Stripe webhook (paid, updated, cancelled, payment failed), Admin → Agencies
  (approve without Stripe, suspend, resume, billing portal, resend welcome, remove)
- deploy/provision-tenants.sh (cron): one container per agency with its own .env and data,
  nginx map for <agency>.quorlasocial.com, upgrades agencies after a rebuild, archives removed data
- New workspaces: first admin only via the emailed one-time setup link (SETUP_TOKEN)
- Meta system user token can be saved in Settings (encrypted) for workspaces that can't edit .env
- docs/multi-tenant.md: DNS, wildcard cert, nginx, sudoers, cron, Stripe setup; tests for the control centre

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:35:23 -04:00

86 lines
4.6 KiB
JavaScript

import { useState } from "react";
import { api } from "../api.js";
import { Button, ErrorNote, Field } from "../ui.jsx";
export default function Login({ onSignedIn, setupNeeded, agency, hasLogo }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [forgot, setForgot] = useState(false);
const submit = async (e) => {
e.preventDefault();
setError(null);
if (setupNeeded && password !== confirm) return setError(new Error("The two passwords don't match."));
setBusy(true);
try {
if (setupNeeded) await api("/api/auth/setup", { method: "POST", body: { name, email, password, setupToken: new URLSearchParams(location.search).get("setup") || "" } });
else await api("/api/auth/login", { method: "POST", body: { email, password } });
onSignedIn();
} catch (err) {
setError(err);
setBusy(false);
}
};
return (
<div className="min-h-screen grid lg:grid-cols-[1fr_520px] bg-white">
<div className="hidden lg:flex flex-col justify-between bg-brand-deep text-white p-12 relative overflow-hidden">
<div className="absolute -right-32 -top-32 size-[420px] rounded-full bg-[radial-gradient(circle,rgba(252,109,69,0.30),transparent_65%)]" />
<img src="/brand/logo-white.png" alt="Quorla" className="h-[34px] w-auto self-start" />
<div className="max-w-md">
<h1 className="text-white text-[34px] font-extrabold leading-[1.15]">Every client account, the team's work and the reports, in one place.</h1>
<p className="text-[#c9c7dd] mt-4 text-[14.5px] leading-relaxed">
Plan, approve and publish to Instagram, Facebook, TikTok, YouTube, LinkedIn and more, with live numbers, AI-run audits and weekly plans, and reports your clients can read.
</p>
</div>
<div className="text-[12px] text-[#9e9bbd]">{agency ? `${agency} workspace · ` : ""}client data is confidential</div>
</div>
<div className="flex items-center justify-center p-8">
<form onSubmit={submit} className="w-full max-w-[340px]">
{hasLogo ? <img src="/brand/agency-logo" alt={agency} className="h-[40px] w-auto max-w-[220px] object-contain mb-8" /> : <img src="/brand/logo.png" alt="Quorla" className="h-[36px] w-auto mb-8" />}
<h2 className="text-[20px] font-extrabold">{setupNeeded ? "Create the admin account" : "Sign in"}</h2>
<p className="text-muted text-[13px] mt-1 mb-6">
{setupNeeded ? "First run: this person can add the rest of the team from Settings." : "Use your agency email."}
</p>
<div className="space-y-3.5">
{setupNeeded && (
<Field label="Your name">
<input className="input" autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} required autoFocus />
</Field>
)}
<Field label="Email">
<input className="input" type="email" autoComplete="username" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus={!setupNeeded} />
</Field>
<Field label="Password" hint={setupNeeded ? "At least 10 characters." : undefined}>
<input className="input" type="password" autoComplete={setupNeeded ? "new-password" : "current-password"} value={password} onChange={(e) => setPassword(e.target.value)} required minLength={setupNeeded ? 10 : undefined} />
</Field>
{setupNeeded && (
<Field label="Confirm password">
<input className="input" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
</Field>
)}
</div>
<ErrorNote error={error} className="mt-4" />
<Button variant="primary" type="submit" loading={busy} className="w-full mt-5">
{setupNeeded ? "Create account" : "Sign in"}
</Button>
{!setupNeeded && (
<div className="mt-4 text-[12.5px] text-center">
{forgot ? (
<p className="text-muted">Ask an admin to reset it in <b>Settings → Team members</b>. They'll give you a temporary password to sign in with, and you then choose your own.</p>
) : (
<button type="button" className="text-brand-deep hover:underline" onClick={() => setForgot(true)}>Forgot password?</button>
)}
</div>
)}
</form>
</div>
</div>
);
}