- 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>
86 lines
4.6 KiB
JavaScript
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>
|
|
);
|
|
}
|