add users

This commit is contained in:
debu-2 2026-10-04 20:42:40 +05:30
parent c01914736b
commit ee23b71f0f
12 changed files with 506 additions and 182 deletions

View file

@ -46,6 +46,17 @@ async def migrate_user_org_columns(connection) -> None:
await connection.execute( await connection.execute(
text("UPDATE users SET member_status = 'active' WHERE member_status IS NULL") text("UPDATE users SET member_status = 'active' WHERE member_status IS NULL")
) )
await connection.execute(
text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invitation_email VARCHAR(320)"
)
)
await connection.execute(
text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN "
"DEFAULT FALSE"
)
)
async def migrate_legacy_user_tables(connection) -> None: async def migrate_legacy_user_tables(connection) -> None:

View file

@ -48,6 +48,8 @@ class Account(Base):
ForeignKey("users.id"), ForeignKey("users.id"),
nullable=True, nullable=True,
) )
invitation_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),

View file

@ -14,7 +14,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings from app.config import get_settings
from app.database import get_postgres_session from app.database import get_postgres_session
from app.models import Account from app.models import Account
from app.schemas import AccountRegister, AccountRead, AuthResponse, LoginRequest from app.schemas import (
AccountRegister,
AccountRead,
AuthResponse,
LoginRequest,
PasswordChangeRequest,
)
from app.services.domain_policy import assert_company_domain_allowed from app.services.domain_policy import assert_company_domain_allowed
from app.services.domain_provisioning import account_is_domain_owner from app.services.domain_provisioning import account_is_domain_owner
from app.services.domain_provisioning import ( from app.services.domain_provisioning import (
@ -223,12 +229,35 @@ async def login(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password.", detail="Incorrect email or password.",
) )
if account.member_status == "invited":
account.member_status = "active"
await session.commit()
return AuthResponse( return AuthResponse(
access_token=create_access_token(account), access_token=create_access_token(account),
account=await account_read(session, account), account=await account_read(session, account),
) )
@router.post("/change-password", response_model=AccountRead)
async def change_password(
payload: PasswordChangeRequest,
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> AccountRead:
validate_password(payload.new_password)
if not password_hash.verify(payload.current_password, account.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect.",
)
account.password_hash = password_hash.hash(payload.new_password)
account.must_change_password = False
if account.member_status == "invited":
account.member_status = "active"
await session.commit()
return await account_read(session, account)
@router.get("/me", response_model=AccountRead) @router.get("/me", response_model=AccountRead)
async def get_me( async def get_me(
account: Account = Depends(get_current_account), account: Account = Depends(get_current_account),

View file

@ -39,8 +39,7 @@ async def add_person(
manager: Account = Depends(require_org_manager), manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session), session: AsyncSession = Depends(get_postgres_session),
) -> OrgMemberRead: ) -> OrgMemberRead:
if not payload.send_invitation: validate_password(payload.password)
validate_password(payload.password or "")
try: try:
member = await create_org_member( member = await create_org_member(

View file

@ -35,6 +35,7 @@ class AccountRead(BaseModel):
phone_number: str phone_number: str
company_domain: str | None company_domain: str | None
is_domain_owner: bool = False is_domain_owner: bool = False
must_change_password: bool = False
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -55,18 +56,22 @@ class OrgMemberCreate(BaseModel):
first_name: str = Field(min_length=1, max_length=80) first_name: str = Field(min_length=1, max_length=80)
last_name: str = Field(min_length=1, max_length=80) last_name: str = Field(min_length=1, max_length=80)
email: EmailStr email: EmailStr
invitation_email: EmailStr | None = None
org_role: Literal["user", "admin"] = "user" org_role: Literal["user", "admin"] = "user"
send_invitation: bool = True send_invitation: bool = True
password: str | None = Field(default=None, min_length=8, max_length=128) force_password_change: bool = False
password: str = Field(min_length=8, max_length=128)
class OrgMemberRead(BaseModel): class OrgMemberRead(BaseModel):
id: UUID id: UUID
email: EmailStr email: EmailStr
invitation_email: EmailStr | None = None
first_name: str first_name: str
last_name: str last_name: str
org_role: str org_role: str
member_status: str member_status: str
must_change_password: bool = False
is_domain_owner: bool = False is_domain_owner: bool = False
groups: list[str] = Field(default_factory=list) groups: list[str] = Field(default_factory=list)
created_at: datetime created_at: datetime
@ -74,6 +79,11 @@ class OrgMemberRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class PasswordChangeRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=8, max_length=128)
class OrgMemberListResponse(BaseModel): class OrgMemberListResponse(BaseModel):
items: list[OrgMemberRead] items: list[OrgMemberRead]
counts: dict[str, int] counts: dict[str, int]

View file

@ -0,0 +1,71 @@
"""Employee invitation emails sent from the platform domain."""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import Account, MailDomain
from app.services.domain_provisioning import get_domain_by_name
from app.services.mail_sender import deliver_email
async def send_employee_invitation(
session: AsyncSession,
*,
member: Account,
manager: Account,
initial_password: str,
force_password_change: bool,
) -> None:
if not member.invitation_email:
raise ValueError("Invitation email is required.")
settings = get_settings()
platform_domain = await get_domain_by_name(session, settings.platform_domain)
if platform_domain is None:
raise ValueError("Platform mail domain is not configured.")
login_url = f"https://{settings.app_public_host}/login"
manager_name = f"{manager.first_name} {manager.last_name}".strip()
company = member.company_domain or settings.platform_domain
change_note = (
"You will be asked to choose a new password immediately after signing in."
if force_password_change
else "You can change your password later from account settings."
)
body_text = (
f"Hi {member.first_name},\n\n"
f"{manager_name} invited you to AGMail for {company}.\n\n"
f"Sign in at: {login_url}\n"
f"Work email: {member.email}\n"
f"Temporary password: {initial_password}\n\n"
f"{change_note}\n\n"
"— AGMail"
)
body_html = (
f"<p>Hi {member.first_name},</p>"
f"<p><strong>{manager_name}</strong> invited you to AGMail for "
f"<strong>{company}</strong>.</p>"
f"<p><a href=\"{login_url}\">Sign in to AGMail</a></p>"
f"<ul>"
f"<li><strong>Work email:</strong> {member.email}</li>"
f"<li><strong>Temporary password:</strong> {initial_password}</li>"
f"</ul>"
f"<p>{change_note}</p>"
f"<p>— AGMail</p>"
)
sender = f"AGMail <noreply@{settings.platform_domain}>"
await deliver_email(
sender=sender,
recipients=[member.invitation_email],
cc=[],
bcc=[],
subject=f"You're invited to {company} on AGMail",
body_text=body_text,
body_html=body_html,
domain=platform_domain,
)

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import secrets import secrets
from datetime import date from datetime import date
from pwdlib import PasswordHash from pwdlib import PasswordHash
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -11,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Account from app.models import Account
from app.schemas import OrgMemberCreate, OrgMemberRead from app.schemas import OrgMemberCreate, OrgMemberRead
from app.services.domain_provisioning import account_is_domain_owner from app.services.domain_provisioning import account_is_domain_owner
from app.services.org_invitations import send_employee_invitation
password_hasher = PasswordHash.recommended() password_hasher = PasswordHash.recommended()
PLACEHOLDER_BIRTHDATE = date(1990, 1, 1) PLACEHOLDER_BIRTHDATE = date(1990, 1, 1)
@ -46,10 +48,12 @@ async def member_read(
return OrgMemberRead( return OrgMemberRead(
id=member.id, id=member.id,
email=member.email, email=member.email,
invitation_email=member.invitation_email,
first_name=member.first_name, first_name=member.first_name,
last_name=member.last_name, last_name=member.last_name,
org_role=role, org_role=role,
member_status=member.member_status, member_status=member.member_status,
must_change_password=member.must_change_password,
is_domain_owner=is_owner, is_domain_owner=is_owner,
groups=[], groups=[],
created_at=member.created_at, created_at=member.created_at,
@ -84,10 +88,6 @@ def _placeholder_phone() -> str:
return f"+1{digits}" return f"+1{digits}"
def _email_local_part(email: str) -> str:
return email.split("@", 1)[0].lower()
async def create_org_member( async def create_org_member(
session: AsyncSession, session: AsyncSession,
*, *,
@ -102,13 +102,18 @@ async def create_org_member(
if not email.endswith(f"@{company_domain}"): if not email.endswith(f"@{company_domain}"):
raise ValueError(f"Email must use your company domain @{company_domain}.") raise ValueError(f"Email must use your company domain @{company_domain}.")
invitation_email = (
str(payload.invitation_email).lower() if payload.invitation_email else None
)
if payload.send_invitation: if payload.send_invitation:
raw_password = secrets.token_urlsafe(24) if not invitation_email:
raise ValueError("A personal invitation email is required.")
if invitation_email.endswith(f"@{company_domain}"):
raise ValueError(
"Use a personal email address for the invitation, not your company domain."
)
member_status = "invited" member_status = "invited"
else: else:
if not payload.password:
raise ValueError("Password is required when invitation email is disabled.")
raw_password = payload.password
member_status = "active" member_status = "active"
existing = await session.scalar( existing = await session.scalar(
@ -135,11 +140,23 @@ async def create_org_member(
company_domain=company_domain, company_domain=company_domain,
org_role=payload.org_role, org_role=payload.org_role,
member_status=member_status, member_status=member_status,
invitation_email=invitation_email,
must_change_password=payload.force_password_change,
created_by_id=manager.id, created_by_id=manager.id,
password_hash=password_hasher.hash(raw_password), password_hash=password_hasher.hash(payload.password),
) )
session.add(member) session.add(member)
await session.flush() await session.flush()
if payload.send_invitation:
await send_employee_invitation(
session,
member=member,
manager=manager,
initial_password=payload.password,
force_password_change=payload.force_password_change,
)
return await member_read(session, member) return await member_read(session, member)

View file

@ -66,6 +66,8 @@ export const updateOrgSecurity = (payload, token) =>
request("/org/security", { method: "PATCH", payload, token }); request("/org/security", { method: "PATCH", payload, token });
export const updateOrgMemberRole = (memberId, payload, token) => export const updateOrgMemberRole = (memberId, payload, token) =>
request(`/people/${memberId}/role`, { method: "PATCH", payload, token }); request(`/people/${memberId}/role`, { method: "PATCH", payload, token });
export const changePassword = (payload, token) =>
request("/auth/change-password", { method: "POST", payload, token });
export const sendEmail = (payload, token) => export const sendEmail = (payload, token) =>
request("/emails", { method: "POST", payload, token }); request("/emails", { method: "POST", payload, token });

View file

@ -0,0 +1,299 @@
import { ArrowRight, Plus } from "lucide-react";
import { useMemo, useState } from "react";
import { createOrgMember } from "../api";
const GROUP_OPTIONS = ["Admin", "HR", "Engineering", "Finance"];
const EMAIL_LOCAL_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
function RequiredLabel({ children }) {
return (
<span className="org-setup-label-text">
{children}
<span className="org-setup-required" aria-hidden="true">*</span>
</span>
);
}
function SetupField({ label, error, children, hint }) {
return (
<label className={`org-setup-field${error ? " has-error" : ""}`}>
{label}
{children}
{hint && <small className="org-setup-field-hint">{hint}</small>}
{error && <span className="org-setup-field-error" role="alert">{error}</span>}
</label>
);
}
function validateForm(form, companyDomain) {
const errors = {};
if (!form.first_name.trim()) errors.first_name = "Please enter a first name.";
if (!form.last_name.trim()) errors.last_name = "Please enter a last name.";
const local = form.email_local.trim();
if (!local) {
errors.email_local = "Please enter a work email address.";
} else if (!EMAIL_LOCAL_PATTERN.test(local)) {
errors.email_local = "Enter a valid email address before @.";
}
if (!form.password.trim()) {
errors.password = "Please set an initial password.";
}
if (form.send_invitation) {
const invite = form.invitation_email.trim();
if (!invite) {
errors.invitation_email = "Please enter a personal email for the invitation.";
} else if (invite.endsWith(`@${companyDomain}`)) {
errors.invitation_email = "Use a personal email, not your company domain.";
}
}
return errors;
}
export default function AddEmployeeForm({
companyDomain,
platformDomain = "gentic.email",
variant = "drawer",
submitLabel = "Create user",
onCreated,
onCancel,
}) {
const token = sessionStorage.getItem("agmail_access_token");
const [form, setForm] = useState({
first_name: "",
last_name: "",
email_local: "",
invitation_email: "",
org_role: "user",
groups: [],
send_invitation: true,
force_password_change: true,
password: "",
});
const [fieldErrors, setFieldErrors] = useState({});
const [submitAttempted, setSubmitAttempted] = useState(false);
const [status, setStatus] = useState({ loading: false, error: "" });
const formValid = useMemo(
() => Object.keys(validateForm(form, companyDomain)).length === 0,
[form, companyDomain],
);
function updateField(field, value) {
setForm((current) => {
const next = { ...current, [field]: value };
if (submitAttempted || fieldErrors[field]) {
const nextErrors = validateForm(next, companyDomain);
setFieldErrors((errors) => ({ ...errors, [field]: nextErrors[field] }));
}
return next;
});
}
function toggleGroup(group) {
setForm((current) => {
const exists = current.groups.includes(group);
return {
...current,
groups: exists
? current.groups.filter((item) => item !== group)
: [...current.groups, group],
};
});
}
async function handleSubmit(event) {
event.preventDefault();
setSubmitAttempted(true);
const errors = validateForm(form, companyDomain);
setFieldErrors(errors);
if (Object.keys(errors).length > 0) {
return;
}
setStatus({ loading: true, error: "" });
const email = `${form.email_local.trim().toLowerCase()}@${companyDomain}`;
try {
const member = await createOrgMember(
{
first_name: form.first_name.trim(),
last_name: form.last_name.trim(),
email,
invitation_email: form.send_invitation
? form.invitation_email.trim().toLowerCase()
: undefined,
org_role: form.org_role,
send_invitation: form.send_invitation,
force_password_change: form.force_password_change,
password: form.password,
},
token,
);
setForm({
first_name: "",
last_name: "",
email_local: "",
invitation_email: "",
org_role: "user",
groups: [],
send_invitation: true,
force_password_change: true,
password: "",
});
setFieldErrors({});
setSubmitAttempted(false);
setStatus({ loading: false, error: "" });
onCreated?.(member);
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
const formClass = variant === "inline"
? "org-setup-form employee-add-form"
: "people-drawer-form employee-add-form";
return (
<form className={formClass} onSubmit={handleSubmit} noValidate>
<div className={variant === "inline" ? "org-setup-form-shell" : undefined}>
<div className="people-drawer-row">
<SetupField
label={<RequiredLabel>First name</RequiredLabel>}
error={fieldErrors.first_name}
>
<input
value={form.first_name}
onChange={(event) => updateField("first_name", event.target.value)}
placeholder="Rahul"
/>
</SetupField>
<SetupField
label={<RequiredLabel>Last name</RequiredLabel>}
error={fieldErrors.last_name}
>
<input
value={form.last_name}
onChange={(event) => updateField("last_name", event.target.value)}
placeholder="Sharma"
/>
</SetupField>
</div>
<SetupField
label={<RequiredLabel>Work email</RequiredLabel>}
error={fieldErrors.email_local}
hint="This will be their login email address on your company domain."
>
<div className="org-setup-email-field">
<input
value={form.email_local}
onChange={(event) => updateField("email_local", event.target.value)}
placeholder="rahul"
/>
<span>@{companyDomain}</span>
</div>
</SetupField>
<SetupField
label={<RequiredLabel>Invitation email</RequiredLabel>}
error={fieldErrors.invitation_email}
hint={`Personal email where AGMail sends the setup invite from @${platformDomain}.`}
>
<input
type="email"
value={form.invitation_email}
onChange={(event) => updateField("invitation_email", event.target.value)}
placeholder="rahul@gmail.com"
disabled={!form.send_invitation}
/>
</SetupField>
<SetupField
label="Role"
hint="Users can access email. User Admins can manage people."
>
<select
value={form.org_role}
onChange={(event) => updateField("org_role", event.target.value)}
>
<option value="user">User</option>
<option value="admin">User Admin</option>
</select>
</SetupField>
<fieldset className="people-groups-field">
<legend>Groups</legend>
<div className="people-groups-options">
{GROUP_OPTIONS.map((group) => (
<button
key={group}
type="button"
className={`people-group-chip${form.groups.includes(group) ? " active" : ""}`}
onClick={() => toggleGroup(group)}
>
{group}
</button>
))}
</div>
</fieldset>
<SetupField
label={<RequiredLabel>Initial password</RequiredLabel>}
error={fieldErrors.password}
hint="Must include uppercase, lowercase, and a number."
>
<input
type="password"
value={form.password}
onChange={(event) => updateField("password", event.target.value)}
placeholder="Set an initial password"
/>
</SetupField>
<label className="people-toggle-row">
<span>
<strong>Send invitation email</strong>
<small>Invite is sent from @{platformDomain} to the personal email above.</small>
</span>
<input
type="checkbox"
checked={form.send_invitation}
onChange={(event) => updateField("send_invitation", event.target.checked)}
/>
</label>
<label className="people-toggle-row">
<span>
<strong>Require password change on first login</strong>
<small>Employee must choose a new password before using AGMail.</small>
</span>
<input
type="checkbox"
checked={form.force_password_change}
onChange={(event) => updateField("force_password_change", event.target.checked)}
/>
</label>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<footer className={variant === "inline" ? "org-setup-actions" : "people-drawer-actions"}>
{onCancel && (
<button type="button" className="button button-outline" onClick={onCancel}>
Cancel
</button>
)}
<button
className="button button-purple org-setup-submit"
type="submit"
disabled={!formValid || status.loading}
>
{status.loading ? "Creating…" : (
variant === "inline" ? (
<><Plus size={16} /> {submitLabel}</>
) : submitLabel
)}
</button>
</footer>
</div>
</form>
);
}

View file

@ -1,5 +1,6 @@
import { ArrowRight, Check } from "lucide-react"; import { ArrowRight, Check } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import AddEmployeeForm from "./AddEmployeeForm";
const STEPS = [ const STEPS = [
{ id: 1, title: "Add domain", hint: "Use your company domain for email" }, { id: 1, title: "Add domain", hint: "Use your company domain for email" },
@ -126,6 +127,7 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
const [details, setDetails] = useState(() => loadDetails(account, companyDomain)); const [details, setDetails] = useState(() => loadDetails(account, companyDomain));
const [fieldErrors, setFieldErrors] = useState({}); const [fieldErrors, setFieldErrors] = useState({});
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [addedEmployees, setAddedEmployees] = useState([]);
useEffect(() => { useEffect(() => {
localStorage.setItem(storageKey(account.id, "step"), String(step)); localStorage.setItem(storageKey(account.id, "step"), String(step));
@ -296,20 +298,41 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
)} )}
{step === 5 && ( {step === 5 && (
<div className="org-setup-form-shell"> <>
<div className="org-setup-panel-intro">
<h2>Add employees</h2> <h2>Add employees</h2>
<p className="org-setup-lead"> <p className="org-setup-section-label">
Invite teammates to <strong>{companyDomainName}</strong>. You can add people now or skip and do this later from People. Invite teammates to <strong>{companyDomainName}</strong>. You can add people now or skip and do this later from People.
</p> </p>
<div className="org-setup-placeholder">
Employee invites will be available here soon.
</div> </div>
{addedEmployees.length > 0 && (
<ul className="employee-added-list">
{addedEmployees.map((employee) => (
<li key={employee.id}>
<strong>{employee.first_name} {employee.last_name}</strong>
<span>{employee.email}</span>
<em>{employee.member_status === "invited" ? "Invited" : "Active"}</em>
</li>
))}
</ul>
)}
<AddEmployeeForm
companyDomain={companyDomainName}
variant="inline"
submitLabel="Add employee"
onCreated={(member) => setAddedEmployees((current) => [...current, member])}
/>
<div className="org-setup-form-shell org-setup-step-actions">
<div className="org-setup-actions"> <div className="org-setup-actions">
<button type="button" className="button button-purple org-setup-submit" onClick={() => setStep(6)}> <button type="button" className="button button-purple org-setup-submit" onClick={() => setStep(6)}>
Continue <ArrowRight size={16} /> {addedEmployees.length > 0 ? "Continue" : "Skip for now"} <ArrowRight size={16} />
</button> </button>
</div> </div>
</div> </div>
</>
)} )}
{step === 6 && ( {step === 6 && (

View file

@ -9,7 +9,8 @@ import {
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { createOrgMember, listOrgMembers } from "../api"; import { listOrgMembers } from "../api";
import AddEmployeeForm from "./AddEmployeeForm";
const STATUS_TABS = [ const STATUS_TABS = [
{ key: "all", label: "All users" }, { key: "all", label: "All users" },
@ -18,8 +19,6 @@ const STATUS_TABS = [
{ key: "suspended", label: "Suspended" }, { key: "suspended", label: "Suspended" },
]; ];
const GROUP_OPTIONS = ["Admin", "HR", "Engineering", "Finance"];
function initials(firstName, lastName) { function initials(firstName, lastName) {
return `${firstName?.[0] || ""}${lastName?.[0] || ""}`.toUpperCase(); return `${firstName?.[0] || ""}${lastName?.[0] || ""}`.toUpperCase();
} }
@ -60,57 +59,6 @@ function StatusBadge({ status }) {
} }
function AddUserPanel({ companyDomain, onClose, onCreated }) { function AddUserPanel({ companyDomain, onClose, onCreated }) {
const token = sessionStorage.getItem("agmail_access_token");
const [form, setForm] = useState({
first_name: "",
last_name: "",
email_local: "",
org_role: "user",
groups: [],
send_invitation: true,
password: "",
});
const [status, setStatus] = useState({ loading: false, error: "" });
function updateField(field, value) {
setForm((current) => ({ ...current, [field]: value }));
}
function toggleGroup(group) {
setForm((current) => {
const exists = current.groups.includes(group);
return {
...current,
groups: exists
? current.groups.filter((item) => item !== group)
: [...current.groups, group],
};
});
}
async function handleSubmit(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const email = `${form.email_local.trim().toLowerCase()}@${companyDomain}`;
try {
const member = await createOrgMember(
{
first_name: form.first_name.trim(),
last_name: form.last_name.trim(),
email,
org_role: form.org_role,
send_invitation: form.send_invitation,
password: form.send_invitation ? undefined : form.password,
},
token,
);
onCreated?.(member);
onClose();
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return ( return (
<div className="people-drawer-scrim" onClick={onClose}> <div className="people-drawer-scrim" onClick={onClose}>
<aside <aside
@ -126,109 +74,14 @@ function AddUserPanel({ companyDomain, onClose, onCreated }) {
</button> </button>
</header> </header>
<form className="people-drawer-form" onSubmit={handleSubmit}> <AddEmployeeForm
<div className="people-drawer-row"> companyDomain={companyDomain}
<label> onCreated={(member) => {
First name <span aria-hidden="true">*</span> onCreated?.(member);
<input onClose();
required }}
value={form.first_name} onCancel={onClose}
onChange={(event) => updateField("first_name", event.target.value)}
placeholder="Rahul"
/> />
</label>
<label>
Last name <span aria-hidden="true">*</span>
<input
required
value={form.last_name}
onChange={(event) => updateField("last_name", event.target.value)}
placeholder="Sharma"
/>
</label>
</div>
<label>
Email <span aria-hidden="true">*</span>
<div className="people-email-field">
<input
required
value={form.email_local}
onChange={(event) => updateField("email_local", event.target.value)}
placeholder="rahul"
/>
<span>@{companyDomain}</span>
</div>
<small>This will be their login email address</small>
</label>
<label>
Role
<select
value={form.org_role}
onChange={(event) => updateField("org_role", event.target.value)}
>
<option value="user">User</option>
<option value="admin">User Admin</option>
</select>
<small>Users can access email. User Admins can manage people.</small>
</label>
<fieldset className="people-groups-field">
<legend>Groups</legend>
<div className="people-groups-options">
{GROUP_OPTIONS.map((group) => (
<button
key={group}
type="button"
className={`people-group-chip${form.groups.includes(group) ? " active" : ""}`}
onClick={() => toggleGroup(group)}
>
{group}
</button>
))}
</div>
<small>Group assignment is saved locally for now.</small>
</fieldset>
<label className="people-toggle-row">
<span>
<strong>Send invitation email</strong>
<small>They will receive an activation link to set their password.</small>
</span>
<input
type="checkbox"
checked={form.send_invitation}
onChange={(event) => updateField("send_invitation", event.target.checked)}
/>
</label>
{!form.send_invitation && (
<label>
Password <span aria-hidden="true">*</span>
<input
required
type="password"
value={form.password}
onChange={(event) => updateField("password", event.target.value)}
placeholder="Set an initial password"
minLength={8}
/>
<small>Must include uppercase, lowercase, and a number.</small>
</label>
)}
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<footer className="people-drawer-actions">
<button type="button" className="button button-outline" onClick={onClose}>
Cancel
</button>
<button className="button button-purple" type="submit" disabled={status.loading}>
{status.loading ? "Creating…" : "Create user"}
</button>
</footer>
</form>
</aside> </aside>
</div> </div>
); );

View file

@ -226,6 +226,14 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.org-setup-summary { margin:0 0 24px; padding:0; list-style:none; display:grid; gap:10px; } .org-setup-summary { margin:0 0 24px; padding:0; list-style:none; display:grid; gap:10px; }
.org-setup-summary li { display:flex; align-items:center; gap:8px; color:#3d3948; font-size:13px; font-weight:500; } .org-setup-summary li { display:flex; align-items:center; gap:8px; color:#3d3948; font-size:13px; font-weight:500; }
.org-setup-summary svg { color:#7357F6; flex-shrink:0; } .org-setup-summary svg { color:#7357F6; flex-shrink:0; }
.employee-add-form .people-drawer-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.employee-add-form .people-groups-field { margin:0; padding:0; border:0; }
.employee-added-list { margin:0 0 20px; padding:0; max-width:720px; list-style:none; display:grid; gap:8px; }
.employee-added-list li { padding:12px 14px; display:grid; grid-template-columns:1fr auto auto; gap:12px; align-items:center; border:1px solid #ECE9F2; border-radius:10px; background:#FAFAFC; font-size:13px; }
.employee-added-list strong { color:#1a1628; }
.employee-added-list span { color:#6f6c7b; }
.employee-added-list em { color:#7357F6; font-size:11px; font-weight:700; font-style:normal; text-transform:uppercase; letter-spacing:.3px; }
.org-setup-step-actions { margin-top:8px; }
.dashboard-sidebar-manage { margin-top:var(--space-4); } .dashboard-sidebar-manage { margin-top:var(--space-4); }
@media(max-width:900px) { @media(max-width:900px) {
.org-setup-head h1 { font-size:28px; } .org-setup-head h1 { font-size:28px; }