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(
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
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
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
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 { 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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue