add users
This commit is contained in:
parent
c01914736b
commit
ee23b71f0f
12 changed files with 506 additions and 182 deletions
|
|
@ -46,6 +46,17 @@ async def migrate_user_org_columns(connection) -> None:
|
|||
await connection.execute(
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ class Account(Base):
|
|||
ForeignKey("users.id"),
|
||||
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))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.config import get_settings
|
||||
from app.database import get_postgres_session
|
||||
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_provisioning import account_is_domain_owner
|
||||
from app.services.domain_provisioning import (
|
||||
|
|
@ -223,12 +229,35 @@ async def login(
|
|||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password.",
|
||||
)
|
||||
if account.member_status == "invited":
|
||||
account.member_status = "active"
|
||||
await session.commit()
|
||||
return AuthResponse(
|
||||
access_token=create_access_token(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)
|
||||
async def get_me(
|
||||
account: Account = Depends(get_current_account),
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@ async def add_person(
|
|||
manager: Account = Depends(require_org_manager),
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> OrgMemberRead:
|
||||
if not payload.send_invitation:
|
||||
validate_password(payload.password or "")
|
||||
validate_password(payload.password)
|
||||
|
||||
try:
|
||||
member = await create_org_member(
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class AccountRead(BaseModel):
|
|||
phone_number: str
|
||||
company_domain: str | None
|
||||
is_domain_owner: bool = False
|
||||
must_change_password: bool = False
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
|
@ -55,18 +56,22 @@ class OrgMemberCreate(BaseModel):
|
|||
first_name: str = Field(min_length=1, max_length=80)
|
||||
last_name: str = Field(min_length=1, max_length=80)
|
||||
email: EmailStr
|
||||
invitation_email: EmailStr | None = None
|
||||
org_role: Literal["user", "admin"] = "user"
|
||||
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):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
invitation_email: EmailStr | None = None
|
||||
first_name: str
|
||||
last_name: str
|
||||
org_role: str
|
||||
member_status: str
|
||||
must_change_password: bool = False
|
||||
is_domain_owner: bool = False
|
||||
groups: list[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
|
|
@ -74,6 +79,11 @@ class OrgMemberRead(BaseModel):
|
|||
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):
|
||||
items: list[OrgMemberRead]
|
||||
counts: dict[str, int]
|
||||
|
|
|
|||
71
backend/app/services/org_invitations.py
Normal file
71
backend/app/services/org_invitations.py
Normal 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,
|
||||
)
|
||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import secrets
|
||||
from datetime import date
|
||||
|
||||
from pwdlib import PasswordHash
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -11,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.models import Account
|
||||
from app.schemas import OrgMemberCreate, OrgMemberRead
|
||||
from app.services.domain_provisioning import account_is_domain_owner
|
||||
from app.services.org_invitations import send_employee_invitation
|
||||
|
||||
password_hasher = PasswordHash.recommended()
|
||||
PLACEHOLDER_BIRTHDATE = date(1990, 1, 1)
|
||||
|
|
@ -46,10 +48,12 @@ async def member_read(
|
|||
return OrgMemberRead(
|
||||
id=member.id,
|
||||
email=member.email,
|
||||
invitation_email=member.invitation_email,
|
||||
first_name=member.first_name,
|
||||
last_name=member.last_name,
|
||||
org_role=role,
|
||||
member_status=member.member_status,
|
||||
must_change_password=member.must_change_password,
|
||||
is_domain_owner=is_owner,
|
||||
groups=[],
|
||||
created_at=member.created_at,
|
||||
|
|
@ -84,10 +88,6 @@ def _placeholder_phone() -> str:
|
|||
return f"+1{digits}"
|
||||
|
||||
|
||||
def _email_local_part(email: str) -> str:
|
||||
return email.split("@", 1)[0].lower()
|
||||
|
||||
|
||||
async def create_org_member(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
|
|
@ -102,13 +102,18 @@ async def create_org_member(
|
|||
if not email.endswith(f"@{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:
|
||||
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"
|
||||
else:
|
||||
if not payload.password:
|
||||
raise ValueError("Password is required when invitation email is disabled.")
|
||||
raw_password = payload.password
|
||||
member_status = "active"
|
||||
|
||||
existing = await session.scalar(
|
||||
|
|
@ -135,11 +140,23 @@ async def create_org_member(
|
|||
company_domain=company_domain,
|
||||
org_role=payload.org_role,
|
||||
member_status=member_status,
|
||||
invitation_email=invitation_email,
|
||||
must_change_password=payload.force_password_change,
|
||||
created_by_id=manager.id,
|
||||
password_hash=password_hasher.hash(raw_password),
|
||||
password_hash=password_hasher.hash(payload.password),
|
||||
)
|
||||
session.add(member)
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ export const updateOrgSecurity = (payload, token) =>
|
|||
request("/org/security", { method: "PATCH", payload, token });
|
||||
export const updateOrgMemberRole = (memberId, 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) =>
|
||||
request("/emails", { method: "POST", payload, token });
|
||||
|
||||
|
|
|
|||
299
frontend/src/components/AddEmployeeForm.jsx
Normal file
299
frontend/src/components/AddEmployeeForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { ArrowRight, Check } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AddEmployeeForm from "./AddEmployeeForm";
|
||||
|
||||
const STEPS = [
|
||||
{ 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 [fieldErrors, setFieldErrors] = useState({});
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [addedEmployees, setAddedEmployees] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey(account.id, "step"), String(step));
|
||||
|
|
@ -296,20 +298,41 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<div className="org-setup-form-shell">
|
||||
<>
|
||||
<div className="org-setup-panel-intro">
|
||||
<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.
|
||||
</p>
|
||||
<div className="org-setup-placeholder">
|
||||
Employee invites will be available here soon.
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import {
|
|||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createOrgMember, listOrgMembers } from "../api";
|
||||
import { listOrgMembers } from "../api";
|
||||
import AddEmployeeForm from "./AddEmployeeForm";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "all", label: "All users" },
|
||||
|
|
@ -18,8 +19,6 @@ const STATUS_TABS = [
|
|||
{ key: "suspended", label: "Suspended" },
|
||||
];
|
||||
|
||||
const GROUP_OPTIONS = ["Admin", "HR", "Engineering", "Finance"];
|
||||
|
||||
function initials(firstName, lastName) {
|
||||
return `${firstName?.[0] || ""}${lastName?.[0] || ""}`.toUpperCase();
|
||||
}
|
||||
|
|
@ -60,57 +59,6 @@ function StatusBadge({ status }) {
|
|||
}
|
||||
|
||||
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 (
|
||||
<div className="people-drawer-scrim" onClick={onClose}>
|
||||
<aside
|
||||
|
|
@ -126,109 +74,14 @@ function AddUserPanel({ companyDomain, onClose, onCreated }) {
|
|||
</button>
|
||||
</header>
|
||||
|
||||
<form className="people-drawer-form" onSubmit={handleSubmit}>
|
||||
<div className="people-drawer-row">
|
||||
<label>
|
||||
First name <span aria-hidden="true">*</span>
|
||||
<input
|
||||
required
|
||||
value={form.first_name}
|
||||
onChange={(event) => updateField("first_name", event.target.value)}
|
||||
placeholder="Rahul"
|
||||
<AddEmployeeForm
|
||||
companyDomain={companyDomain}
|
||||
onCreated={(member) => {
|
||||
onCreated?.(member);
|
||||
onClose();
|
||||
}}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 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; }
|
||||
.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); }
|
||||
@media(max-width:900px) {
|
||||
.org-setup-head h1 { font-size:28px; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue