email template
This commit is contained in:
parent
ebbb582e99
commit
2451045693
15 changed files with 447 additions and 156 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import DashboardPage from "./components/DashboardPage";
|
||||
import InviteAcceptPage from "./components/InviteAcceptPage";
|
||||
import LoginPage from "./components/LoginPage";
|
||||
import MobileLanding from "./components/MobileLanding";
|
||||
import SignupPage from "./components/SignupPage";
|
||||
|
|
@ -12,6 +13,9 @@ export default function App() {
|
|||
if (window.location.pathname === "/login") {
|
||||
return <LoginPage />;
|
||||
}
|
||||
if (window.location.pathname.startsWith("/invite/")) {
|
||||
return <InviteAcceptPage />;
|
||||
}
|
||||
if (window.location.pathname === "/signup") {
|
||||
return <SignupPage />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ async function request(path, { method = "GET", payload, token, basicAuth } = {})
|
|||
|
||||
export const login = (payload) =>
|
||||
request("/auth/login", { method: "POST", payload });
|
||||
export const previewInvite = (token) =>
|
||||
request(`/auth/invites/${encodeURIComponent(token)}`);
|
||||
export const acceptInvite = (token, payload) =>
|
||||
request(`/auth/invites/${encodeURIComponent(token)}/accept`, {
|
||||
method: "POST",
|
||||
payload,
|
||||
});
|
||||
export const register = (payload) =>
|
||||
request("/auth/register", { method: "POST", payload });
|
||||
export const getCurrentAccount = (token) =>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ function validateForm(form, companyDomain) {
|
|||
} else if (!EMAIL_LOCAL_PATTERN.test(local)) {
|
||||
errors.email_local = "Enter a valid email address before @.";
|
||||
}
|
||||
if (!form.password.trim()) {
|
||||
if (!form.send_invitation && !form.password.trim()) {
|
||||
errors.password = "Please set an initial password.";
|
||||
}
|
||||
if (form.send_invitation) {
|
||||
|
|
@ -178,8 +178,8 @@ export default function AddEmployeeForm({
|
|||
section_id: form.section_id || undefined,
|
||||
org_role: form.org_role,
|
||||
send_invitation: form.send_invitation,
|
||||
force_password_change: form.force_password_change,
|
||||
password: form.password,
|
||||
force_password_change: form.send_invitation ? false : form.force_password_change,
|
||||
password: form.send_invitation ? undefined : form.password,
|
||||
},
|
||||
token,
|
||||
);
|
||||
|
|
@ -327,7 +327,7 @@ export default function AddEmployeeForm({
|
|||
<SetupField
|
||||
label={form.send_invitation ? <RequiredLabel>Invitation email</RequiredLabel> : "Invitation email"}
|
||||
error={fieldErrors.invitation_email}
|
||||
hint={`Personal email where aGentic Email sends the setup invite from @${platformDomain}.`}
|
||||
hint={`Personal email where we send the setup invite from invites@${platformDomain}.`}
|
||||
>
|
||||
<InputWrap icon={Mail}>
|
||||
<input
|
||||
|
|
@ -340,21 +340,6 @@ export default function AddEmployeeForm({
|
|||
</InputWrap>
|
||||
</SetupField>
|
||||
|
||||
<SetupField
|
||||
label={<RequiredLabel>Initial password</RequiredLabel>}
|
||||
error={fieldErrors.password}
|
||||
hint="Must include uppercase, lowercase, and a number."
|
||||
>
|
||||
<InputWrap icon={Lock}>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => updateField("password", event.target.value)}
|
||||
placeholder="Set an initial password"
|
||||
/>
|
||||
</InputWrap>
|
||||
</SetupField>
|
||||
|
||||
<div className="add-user-checkboxes">
|
||||
<label className="add-user-checkbox">
|
||||
<input
|
||||
|
|
@ -364,23 +349,42 @@ export default function AddEmployeeForm({
|
|||
/>
|
||||
<span>
|
||||
<strong>Send invitation email</strong>
|
||||
<small>They will receive an activation link to set their password.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="add-user-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.force_password_change}
|
||||
onChange={(event) => updateField("force_password_change", event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Require password change on first login</strong>
|
||||
<small>User must create a new password when they sign in for the first time.</small>
|
||||
<small>They will receive a secure link to set their password (expires in 48 hours).</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!form.send_invitation && (
|
||||
<>
|
||||
<SetupField
|
||||
label={<RequiredLabel>Initial password</RequiredLabel>}
|
||||
error={fieldErrors.password}
|
||||
hint="Must include uppercase, lowercase, and a number."
|
||||
>
|
||||
<InputWrap icon={Lock}>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => updateField("password", event.target.value)}
|
||||
placeholder="Set an initial password"
|
||||
/>
|
||||
</InputWrap>
|
||||
</SetupField>
|
||||
|
||||
<label className="add-user-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.force_password_change}
|
||||
onChange={(event) => updateField("force_password_change", event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Require password change on first login</strong>
|
||||
<small>User must create a new password when they sign in for the first time.</small>
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
|
||||
<footer className={variant === "inline" ? "org-setup-actions" : "add-user-actions"}>
|
||||
|
|
|
|||
129
frontend/src/components/InviteAcceptPage.jsx
Normal file
129
frontend/src/components/InviteAcceptPage.jsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { ArrowRight, LockKeyhole } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { acceptInvite, previewInvite } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
|
||||
function inviteTokenFromPath() {
|
||||
const match = window.location.pathname.match(/^\/invite\/([^/]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
}
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
const token = inviteTokenFromPath();
|
||||
const [invite, setInvite] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoadError("This invitation link is invalid.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
previewInvite(token)
|
||||
.then((data) => setInvite(data))
|
||||
.catch((error) => setLoadError(error.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
setStatus({ loading: true, error: "" });
|
||||
const form = new FormData(event.currentTarget);
|
||||
const password = form.get("password");
|
||||
const confirm = form.get("confirm_password");
|
||||
if (password !== confirm) {
|
||||
setStatus({ loading: false, error: "Passwords do not match." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await acceptInvite(token, { password });
|
||||
sessionStorage.setItem("agmail_access_token", result.access_token);
|
||||
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
|
||||
window.location.assign("/app");
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Invitation"
|
||||
title={<>Setting up<br /><em>your account.</em></>}
|
||||
description="Verifying your invitation link…"
|
||||
>
|
||||
<p>Loading invitation…</p>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError || !invite) {
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Invitation"
|
||||
title={<>This link<br /><em>is not valid.</em></>}
|
||||
description="The invitation may have expired or already been used."
|
||||
>
|
||||
<p className="form-error" role="alert">{loadError || "Invitation not found."}</p>
|
||||
<p className="auth-switch"><a href="/login">Go to sign in</a></p>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Invitation"
|
||||
title={<>Join <em>{invite.organization}</em></>}
|
||||
description={`${invite.invited_by} invited you to set up your work email.`}
|
||||
>
|
||||
<>
|
||||
<div className="card-heading">
|
||||
<h2>Set up your account</h2>
|
||||
<p>
|
||||
Your work email is <strong>{invite.work_email}</strong>.
|
||||
Choose a password to activate your inbox.
|
||||
</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Password
|
||||
<div className="input-wrap">
|
||||
<LockKeyhole size={16} />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Create a password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<div className="input-wrap">
|
||||
<LockKeyhole size={16} />
|
||||
<input
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
placeholder="Repeat your password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<p className="auth-switch" style={{ marginTop: 0 }}>
|
||||
Must include uppercase, lowercase, and a number.
|
||||
</p>
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Activating…" : "Set up your account"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -131,10 +131,14 @@ export function validateImportRows(rows, { companyDomain, sendInvitation }) {
|
|||
seenEmails.add(workEmail);
|
||||
|
||||
const password = row.password.trim();
|
||||
if (!password) {
|
||||
rowErrors.push("Initial password is required.");
|
||||
} else if (!PASSWORD_PATTERN.test(password)) {
|
||||
rowErrors.push("Password must be at least 8 characters with upper, lower, and a number.");
|
||||
if (!sendInvitation) {
|
||||
if (!password) {
|
||||
rowErrors.push("Initial password is required.");
|
||||
} else if (!PASSWORD_PATTERN.test(password)) {
|
||||
rowErrors.push("Password must be at least 8 characters with upper, lower, and a number.");
|
||||
}
|
||||
} else if (password) {
|
||||
rowErrors.push("Password is not needed when sending invitation emails.");
|
||||
}
|
||||
|
||||
const invitationEmail = row.invitation_email.trim().toLowerCase();
|
||||
|
|
@ -171,7 +175,7 @@ export function rowsToImportPayload(rows, options) {
|
|||
? row.work_email.trim().toLowerCase()
|
||||
: `${row.work_email.trim().toLowerCase()}@${companyDomain}`,
|
||||
invitation_email: row.invitation_email.trim() || undefined,
|
||||
password: row.password.trim(),
|
||||
password: row.password.trim() || undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue