ui enhancement company

This commit is contained in:
debu-2 2026-10-04 20:32:18 +05:30
parent cc3f3f3de5
commit c01914736b
14 changed files with 876 additions and 159 deletions

View file

@ -0,0 +1,24 @@
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.models import Account
from app.routers.auth import get_current_account
from app.services.domain_provisioning import account_is_domain_owner
async def require_domain_owner(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> Account:
if account.account_type != "company" or not account.company_domain:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Company accounts only.",
)
if not await account_is_domain_owner(session, account.id, account.company_domain):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the domain owner can manage administrators.",
)
return account

View file

@ -14,6 +14,7 @@ from app.routers import (
domains, domains,
emails, emails,
groups, groups,
org,
people, people,
sticky_notes, sticky_notes,
) )
@ -160,6 +161,7 @@ app.include_router(domains.router, prefix=settings.api_prefix)
app.include_router(people.router, prefix=settings.api_prefix) app.include_router(people.router, prefix=settings.api_prefix)
app.include_router(groups.router, prefix=settings.api_prefix) app.include_router(groups.router, prefix=settings.api_prefix)
app.include_router(aliases.router, prefix=settings.api_prefix) app.include_router(aliases.router, prefix=settings.api_prefix)
app.include_router(org.router, prefix=settings.api_prefix)
app.include_router(dns_setup.router, prefix=settings.api_prefix) app.include_router(dns_setup.router, prefix=settings.api_prefix)

View file

@ -136,6 +136,26 @@ class SmtpCredential(Base):
domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials") domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials")
class OrgSecuritySettings(Base):
__tablename__ = "org_security_settings"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
company_domain: Mapped[str] = mapped_column(String(253), unique=True, index=True)
require_strong_passwords: Mapped[bool] = mapped_column(Boolean, default=True)
require_mfa_admins: Mapped[bool] = mapped_column(Boolean, default=True)
require_mfa_all_users: Mapped[bool] = mapped_column(Boolean, default=False)
session_timeout_minutes: Mapped[int] = mapped_column(Integer, default=60)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
class MailGroup(Base): class MailGroup(Base):
__tablename__ = "mail_groups" __tablename__ = "mail_groups"
__table_args__ = ( __table_args__ = (

View file

@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.deps.org_manager import require_org_manager
from app.models import Account
from app.schemas import OrgSecuritySettingsRead, OrgSecuritySettingsUpdate
from app.services.org_security import read_security_settings, update_security_settings
router = APIRouter(prefix="/org", tags=["organization"])
@router.get("/security", response_model=OrgSecuritySettingsRead)
async def get_org_security(
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgSecuritySettingsRead:
return await read_security_settings(session, manager.company_domain)
@router.patch("/security", response_model=OrgSecuritySettingsRead)
async def patch_org_security(
payload: OrgSecuritySettingsUpdate,
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgSecuritySettingsRead:
try:
settings = await update_security_settings(
session,
manager.company_domain,
payload,
)
await session.commit()
return settings
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error

View file

@ -1,32 +1,29 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session from app.database import get_postgres_session
from app.deps.domain_owner import require_domain_owner
from app.models import Account from app.models import Account
from app.routers.auth import get_current_account, validate_password from app.routers.auth import validate_password
from app.schemas import OrgMemberCreate, OrgMemberListResponse, OrgMemberRead from app.schemas import (
OrgMemberCreate,
OrgMemberListResponse,
OrgMemberRead,
OrgMemberRoleUpdate,
)
from app.services.org_members import ( from app.services.org_members import (
can_manage_org_members,
create_org_member, create_org_member,
list_org_members, list_org_members,
update_org_member_role,
) )
from app.deps.org_manager import require_org_manager
router = APIRouter(prefix="/people", tags=["people"]) router = APIRouter(prefix="/people", tags=["people"])
async def require_org_manager(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> Account:
if not await can_manage_org_members(session, account):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only company admins can manage people.",
)
return account
@router.get("", response_model=OrgMemberListResponse) @router.get("", response_model=OrgMemberListResponse)
async def get_people( async def get_people(
manager: Account = Depends(require_org_manager), manager: Account = Depends(require_org_manager),
@ -64,3 +61,26 @@ async def add_person(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="An account with those details already exists.", detail="An account with those details already exists.",
) from error ) from error
@router.patch("/{member_id}/role", response_model=OrgMemberRead)
async def update_member_role(
member_id: UUID,
payload: OrgMemberRoleUpdate,
owner: Account = Depends(require_domain_owner),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgMemberRead:
try:
member = await update_org_member_role(
session,
manager=owner,
member_id=member_id,
org_role=payload.org_role,
)
await session.commit()
return member
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error

View file

@ -126,6 +126,26 @@ class MailAliasListResponse(BaseModel):
counts: dict[str, int] counts: dict[str, int]
class OrgSecuritySettingsRead(BaseModel):
require_strong_passwords: bool
require_mfa_admins: bool
require_mfa_all_users: bool
session_timeout_minutes: int
model_config = ConfigDict(from_attributes=True)
class OrgSecuritySettingsUpdate(BaseModel):
require_strong_passwords: bool | None = None
require_mfa_admins: bool | None = None
require_mfa_all_users: bool | None = None
session_timeout_minutes: int | None = Field(default=None, ge=15, le=1440)
class OrgMemberRoleUpdate(BaseModel):
org_role: Literal["user", "admin"]
class EmailCreate(BaseModel): class EmailCreate(BaseModel):
recipients: list[EmailStr] = Field(min_length=1) recipients: list[EmailStr] = Field(min_length=1)
cc: list[EmailStr] = Field(default_factory=list) cc: list[EmailStr] = Field(default_factory=list)

View file

@ -141,3 +141,41 @@ async def create_org_member(
session.add(member) session.add(member)
await session.flush() await session.flush()
return await member_read(session, member) return await member_read(session, member)
async def update_org_member_role(
session: AsyncSession,
*,
manager: Account,
member_id,
org_role: str,
) -> OrgMemberRead:
company_domain = manager.company_domain
if company_domain is None:
raise ValueError("Company domain is required.")
if not await account_is_domain_owner(
session,
manager.id,
company_domain,
):
raise ValueError("Only the domain owner can assign roles.")
member = await session.scalar(
select(Account).where(
Account.id == member_id,
Account.company_domain == company_domain,
)
)
if member is None:
raise ValueError("User not found in your organization.")
if await account_is_domain_owner(session, member.id, company_domain):
raise ValueError("The domain owner role cannot be changed.")
if member.id == manager.id:
raise ValueError("You cannot change your own role.")
member.org_role = org_role
await session.flush()
return await member_read(session, member)

View file

@ -0,0 +1,55 @@
"""Organization security settings."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import OrgSecuritySettings
from app.schemas import OrgSecuritySettingsRead, OrgSecuritySettingsUpdate
ALLOWED_SESSION_TIMEOUTS = {15, 30, 60, 120, 480, 1440}
async def get_or_create_security_settings(
session: AsyncSession,
company_domain: str,
) -> OrgSecuritySettings:
settings = await session.scalar(
select(OrgSecuritySettings).where(
OrgSecuritySettings.company_domain == company_domain
)
)
if settings is None:
settings = OrgSecuritySettings(company_domain=company_domain)
session.add(settings)
await session.flush()
return settings
async def read_security_settings(
session: AsyncSession,
company_domain: str,
) -> OrgSecuritySettingsRead:
settings = await get_or_create_security_settings(session, company_domain)
return OrgSecuritySettingsRead.model_validate(settings)
async def update_security_settings(
session: AsyncSession,
company_domain: str,
payload: OrgSecuritySettingsUpdate,
) -> OrgSecuritySettingsRead:
settings = await get_or_create_security_settings(session, company_domain)
updates = payload.model_dump(exclude_unset=True)
if "session_timeout_minutes" in updates:
timeout = updates["session_timeout_minutes"]
if timeout not in ALLOWED_SESSION_TIMEOUTS:
raise ValueError(
"Session timeout must be one of: "
+ ", ".join(str(value) for value in sorted(ALLOWED_SESSION_TIMEOUTS))
)
for field, value in updates.items():
setattr(settings, field, value)
await session.flush()
return OrgSecuritySettingsRead.model_validate(settings)

View file

@ -60,6 +60,12 @@ export const listMailAliases = (token) =>
request("/aliases", { token }); request("/aliases", { token });
export const createMailAlias = (payload, token) => export const createMailAlias = (payload, token) =>
request("/aliases", { method: "POST", payload, token }); request("/aliases", { method: "POST", payload, token });
export const getOrgSecurity = (token) =>
request("/org/security", { token });
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 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,158 @@
import { Crown, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { listOrgMembers, updateOrgMemberRole } from "../api";
function initials(firstName, lastName) {
return `${firstName?.[0] || ""}${lastName?.[0] || ""}`.toUpperCase();
}
function roleLabel(member) {
if (member.is_domain_owner || member.org_role === "owner") return "Owner";
if (member.org_role === "admin") return "User Admin";
return "User";
}
export default function CompanyAdministrators({ account }) {
const token = sessionStorage.getItem("agmail_access_token");
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [updatingId, setUpdatingId] = useState(null);
const loadMembers = useCallback(async () => {
setLoading(true);
setError("");
try {
const data = await listOrgMembers(token);
setMembers(data.items);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
loadMembers();
}, [loadMembers]);
const filteredMembers = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return members;
return members.filter((member) => {
const haystack = `${member.first_name} ${member.last_name} ${member.email}`.toLowerCase();
return haystack.includes(query);
});
}, [members, search]);
async function handleRoleChange(member, nextRole) {
if (member.is_domain_owner || member.org_role === "owner") return;
if (member.id === account.id) return;
setUpdatingId(member.id);
setError("");
try {
const updated = await updateOrgMemberRole(member.id, { org_role: nextRole }, token);
setMembers((current) => current.map((item) => (
item.id === updated.id ? updated : item
)));
} catch (err) {
setError(err.message);
} finally {
setUpdatingId(null);
}
}
return (
<section className="company-people">
<header className="company-people-head">
<div>
<h1>Administrators</h1>
<p>Assign roles and control who can manage your organization.</p>
</div>
</header>
<div className="people-filters">
<label className="people-search">
<Search size={16} />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search users…"
/>
</label>
</div>
{error && <p className="form-error" role="alert">{error}</p>}
<div className="people-table-wrap">
<table className="people-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={4} className="people-table-empty">Loading administrators…</td>
</tr>
) : filteredMembers.length === 0 ? (
<tr>
<td colSpan={4} className="people-table-empty">No users match your search.</td>
</tr>
) : (
filteredMembers.map((member) => {
const isOwner = member.is_domain_owner || member.org_role === "owner";
const isSelf = member.id === account.id;
const roleValue = isOwner ? "owner" : member.org_role;
return (
<tr key={member.id}>
<td>
<div className="people-name-cell">
<span className="people-avatar">{initials(member.first_name, member.last_name)}</span>
<span>
{member.first_name} {member.last_name}
{isOwner && (
<em className="admin-owner-badge">
<Crown size={12} /> Owner
</em>
)}
</span>
</div>
</td>
<td>{member.email}</td>
<td>{member.member_status}</td>
<td>
{isOwner || isSelf ? (
<span className="admin-role-readonly">{roleLabel(member)}</span>
) : (
<select
className="admin-role-select"
value={roleValue}
disabled={updatingId === member.id}
onChange={(event) => handleRoleChange(member, event.target.value)}
>
<option value="user">User</option>
<option value="admin">User Admin</option>
</select>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<p className="admin-role-hint">
User Admins can manage people, groups, aliases, and security settings.
Only the domain owner can assign administrator roles.
</p>
</section>
);
}

View file

@ -1,5 +1,5 @@
import { ArrowRight, Check } from "lucide-react"; import { ArrowRight, Check } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
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" },
@ -28,6 +28,8 @@ const COUNTRIES = [
"Other", "Other",
]; ];
const EMAIL_LOCAL_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
function storageKey(accountId, suffix) { function storageKey(accountId, suffix) {
return `agmail_org_${suffix}_${accountId}`; return `agmail_org_${suffix}_${accountId}`;
} }
@ -38,37 +40,126 @@ function loadStep(accountId) {
return 4; return 4;
} }
function parseEmailLocal(email, domain) {
const normalized = String(email || "").toLowerCase();
const suffix = `@${domain}`;
if (normalized.endsWith(suffix)) {
return normalized.slice(0, -suffix.length);
}
const at = normalized.indexOf("@");
return at > 0 ? normalized.slice(0, at) : normalized;
}
function loadDetails(account, companyDomain) { function loadDetails(account, companyDomain) {
const domain = companyDomain?.domain_name || account.company_domain || "";
try { try {
const raw = localStorage.getItem(storageKey(account.id, "details")); const raw = localStorage.getItem(storageKey(account.id, "details"));
if (raw) return JSON.parse(raw); if (raw) {
const saved = JSON.parse(raw);
return {
companyName: saved.companyName || "",
orgSize: saved.orgSize || "51-200 employees",
country: saved.country || "India",
contactName: saved.contactName || "",
emailLocal: saved.emailLocal || parseEmailLocal(saved.workEmail || account.email, domain),
};
}
} catch { } catch {
/* ignore */ /* ignore */
} }
return { return {
companyName: companyDomain?.domain_name?.split(".")[0] || "", companyName: domain?.split(".")[0] || "",
orgSize: "51-200 employees", orgSize: "51-200 employees",
country: "India", country: "India",
contactName: `${account.first_name} ${account.last_name}`.trim(), contactName: `${account.first_name} ${account.last_name}`.trim(),
workEmail: account.email, emailLocal: parseEmailLocal(account.email, domain),
}; };
} }
function validateDetails(details) {
const errors = {};
if (!details.companyName.trim()) {
errors.companyName = "Please enter your company name.";
}
if (!details.contactName.trim()) {
errors.contactName = "Please enter your name.";
}
const local = details.emailLocal.trim();
if (!local) {
errors.emailLocal = "Please enter your admin email.";
} else if (!EMAIL_LOCAL_PATTERN.test(local)) {
errors.emailLocal = "Enter a valid email address before @.";
}
return errors;
}
export function isOrgSetupComplete(accountId) { export function isOrgSetupComplete(accountId) {
return localStorage.getItem(storageKey(accountId, "complete")) === "1"; return localStorage.getItem(storageKey(accountId, "complete")) === "1";
} }
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,
}) {
return (
<label className={`org-setup-field${error ? " has-error" : ""}`}>
{label}
{children}
{error && <span className="org-setup-field-error" role="alert">{error}</span>}
</label>
);
}
export default function CompanyOverviewSetup({ account, companyDomain, onComplete }) { export default function CompanyOverviewSetup({ account, companyDomain, onComplete }) {
const companyDomainName = companyDomain?.domain_name || account.company_domain || "";
const [step, setStep] = useState(() => loadStep(account.id)); const [step, setStep] = useState(() => loadStep(account.id));
const [details, setDetails] = useState(() => loadDetails(account, companyDomain)); const [details, setDetails] = useState(() => loadDetails(account, companyDomain));
const [fieldErrors, setFieldErrors] = useState({});
const [submitAttempted, setSubmitAttempted] = useState(false);
useEffect(() => { useEffect(() => {
localStorage.setItem(storageKey(account.id, "step"), String(step)); localStorage.setItem(storageKey(account.id, "step"), String(step));
}, [account.id, step]); }, [account.id, step]);
const formValid = useMemo(() => Object.keys(validateDetails(details)).length === 0, [details]);
function updateField(field, value) {
setDetails((current) => {
const next = { ...current, [field]: value };
if (submitAttempted || fieldErrors[field]) {
const nextErrors = validateDetails(next);
setFieldErrors((errors) => ({
...errors,
[field]: nextErrors[field],
}));
}
return next;
});
}
function saveDetails(event) { function saveDetails(event) {
event.preventDefault(); event.preventDefault();
localStorage.setItem(storageKey(account.id, "details"), JSON.stringify(details)); setSubmitAttempted(true);
const errors = validateDetails(details);
setFieldErrors(errors);
if (Object.keys(errors).length > 0) {
return;
}
const payload = {
...details,
workEmail: `${details.emailLocal.trim().toLowerCase()}@${companyDomainName}`,
};
localStorage.setItem(storageKey(account.id, "details"), JSON.stringify(payload));
setStep(5); setStep(5);
} }
@ -87,17 +178,21 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
<div className="org-setup-layout"> <div className="org-setup-layout">
<ol className="org-setup-steps" aria-label="Setup progress"> <ol className="org-setup-steps" aria-label="Setup progress">
{STEPS.map((item) => { {STEPS.map((item, index) => {
const complete = item.id < step || (item.id <= 3); const complete = item.id < step || (item.id <= 3);
const active = item.id === step; const active = item.id === step;
const isLast = index === STEPS.length - 1;
return ( return (
<li <li
key={item.id} key={item.id}
className={`org-setup-step${complete ? " complete" : ""}${active ? " active" : ""}`} className={`org-setup-step${complete ? " complete" : ""}${active ? " active" : ""}`}
> >
<span className="org-setup-step-mark" aria-hidden="true"> <div className="org-setup-step-indicator">
{complete ? <Check size={14} strokeWidth={2.5} /> : item.id} <span className="org-setup-step-mark" aria-hidden="true">
</span> {complete ? <Check size={14} strokeWidth={2.5} /> : item.id}
</span>
{!isLast && <span className="org-setup-step-connector" aria-hidden="true" />}
</div>
<span className="org-setup-step-copy"> <span className="org-setup-step-copy">
<strong>{item.title}</strong> <strong>{item.title}</strong>
<small>{item.hint}</small> <small>{item.hint}</small>
@ -110,105 +205,118 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
<div className="org-setup-panel"> <div className="org-setup-panel">
{step === 4 && ( {step === 4 && (
<> <>
<h2>Company details</h2> <div className="org-setup-panel-intro">
<p className="org-setup-section-label">Organization information</p> <h2>Company details</h2>
<form className="org-setup-form" onSubmit={saveDetails}> <p className="org-setup-section-label">Tell us a little about your organization.</p>
<label> </div>
Company name <span aria-hidden="true">*</span> <form className="org-setup-form" onSubmit={saveDetails} noValidate>
<input <div className="org-setup-form-shell">
required <SetupField
value={details.companyName} label={<RequiredLabel>Company name</RequiredLabel>}
onChange={(event) => setDetails((current) => ({ error={fieldErrors.companyName}
...current,
companyName: event.target.value,
}))}
placeholder="Acme Pvt Ltd"
/>
</label>
<label>
Organization size <span aria-hidden="true">*</span>
<select
required
value={details.orgSize}
onChange={(event) => setDetails((current) => ({
...current,
orgSize: event.target.value,
}))}
> >
{ORG_SIZES.map((size) => ( <input
<option key={size} value={size}>{size}</option> value={details.companyName}
))} onChange={(event) => updateField("companyName", event.target.value)}
</select> placeholder="Acme Pvt Ltd"
</label> aria-invalid={Boolean(fieldErrors.companyName)}
<label> />
Country <span aria-hidden="true">*</span> </SetupField>
<select
required <div className="org-setup-form-row">
value={details.country} <SetupField
onChange={(event) => setDetails((current) => ({ label={<RequiredLabel>Organization size</RequiredLabel>}
...current, error={fieldErrors.orgSize}
country: event.target.value, >
}))} <select
value={details.orgSize}
onChange={(event) => updateField("orgSize", event.target.value)}
>
{ORG_SIZES.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</SetupField>
<SetupField
label={<RequiredLabel>Country</RequiredLabel>}
error={fieldErrors.country}
>
<select
value={details.country}
onChange={(event) => updateField("country", event.target.value)}
>
{COUNTRIES.map((country) => (
<option key={country} value={country}>{country}</option>
))}
</select>
</SetupField>
</div>
<SetupField
label={<RequiredLabel>Your name</RequiredLabel>}
error={fieldErrors.contactName}
> >
{COUNTRIES.map((country) => ( <input
<option key={country} value={country}>{country}</option> value={details.contactName}
))} onChange={(event) => updateField("contactName", event.target.value)}
</select> aria-invalid={Boolean(fieldErrors.contactName)}
</label> />
<label> </SetupField>
Your name <span aria-hidden="true">*</span>
<input <SetupField
required label={<RequiredLabel>Your admin email</RequiredLabel>}
value={details.contactName} error={fieldErrors.emailLocal}
onChange={(event) => setDetails((current) => ({ >
...current, <div className="org-setup-email-field">
contactName: event.target.value, <input
}))} value={details.emailLocal}
/> onChange={(event) => updateField("emailLocal", event.target.value)}
</label> placeholder="suyash"
<label> aria-invalid={Boolean(fieldErrors.emailLocal)}
Your work email <span aria-hidden="true">*</span> />
<input <span>@{companyDomainName}</span>
required </div>
type="email" <small className="org-setup-field-hint">
value={details.workEmail} This becomes your login address on your company domain.
onChange={(event) => setDetails((current) => ({ </small>
...current, </SetupField>
workEmail: event.target.value,
}))} <div className="org-setup-actions">
/> <button
</label> type="submit"
<div className="org-setup-actions"> className="button button-purple org-setup-submit"
<button type="submit" className="button button-purple"> disabled={!formValid}
Continue <ArrowRight size={16} /> >
</button> Continue <ArrowRight size={16} />
</button>
</div>
</div> </div>
</form> </form>
</> </>
)} )}
{step === 5 && ( {step === 5 && (
<> <div className="org-setup-form-shell">
<h2>Add employees</h2> <h2>Add employees</h2>
<p className="org-setup-lead"> <p className="org-setup-lead">
Invite teammates to <strong>{companyDomain?.domain_name}</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"> <div className="org-setup-placeholder">
Employee invites will be available here soon. Employee invites will be available here soon.
</div> </div>
<div className="org-setup-actions"> <div className="org-setup-actions">
<button type="button" className="button button-purple" onClick={() => setStep(6)}> <button type="button" className="button button-purple org-setup-submit" onClick={() => setStep(6)}>
Continue <ArrowRight size={16} /> Continue <ArrowRight size={16} />
</button> </button>
</div> </div>
</> </div>
)} )}
{step === 6 && ( {step === 6 && (
<> <div className="org-setup-form-shell">
<h2>Finish setup</h2> <h2>Finish setup</h2>
<p className="org-setup-lead"> <p className="org-setup-lead">
Domain <strong>{companyDomain?.domain_name}</strong> is connected. You&apos;re ready to use AGMail. Domain <strong>{companyDomainName}</strong> is connected. You&apos;re ready to use AGMail.
</p> </p>
<ul className="org-setup-summary"> <ul className="org-setup-summary">
<li><Check size={14} /> Domain added and verified</li> <li><Check size={14} /> Domain added and verified</li>
@ -216,11 +324,11 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
<li><Check size={14} /> Organization details saved</li> <li><Check size={14} /> Organization details saved</li>
</ul> </ul>
<div className="org-setup-actions"> <div className="org-setup-actions">
<button type="button" className="button button-purple" onClick={finishSetup}> <button type="button" className="button button-purple org-setup-submit" onClick={finishSetup}>
Go to inbox <ArrowRight size={16} /> Go to inbox <ArrowRight size={16} />
</button> </button>
</div> </div>
</> </div>
)} )}
</div> </div>
</div> </div>

View file

@ -0,0 +1,151 @@
import { useCallback, useEffect, useState } from "react";
import { getOrgSecurity, updateOrgSecurity } from "../api";
const SESSION_TIMEOUTS = [
{ value: 15, label: "15 minutes" },
{ value: 30, label: "30 minutes" },
{ value: 60, label: "1 hour" },
{ value: 120, label: "2 hours" },
{ value: 480, label: "8 hours" },
{ value: 1440, label: "24 hours" },
];
function ToggleRow({ title, description, checked, disabled, onChange }) {
return (
<div className="security-setting-row">
<div>
<strong>{title}</strong>
<p>{description}</p>
</div>
<label className="security-toggle">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
<span aria-hidden="true" />
</label>
</div>
);
}
export default function CompanySecurity() {
const token = sessionStorage.getItem("agmail_access_token");
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const loadSettings = useCallback(async () => {
setLoading(true);
setError("");
try {
const data = await getOrgSecurity(token);
setSettings(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
loadSettings();
}, [loadSettings]);
async function savePatch(patch) {
setSaving(true);
setError("");
setNotice("");
try {
const data = await updateOrgSecurity(patch, token);
setSettings(data);
setNotice("Security settings saved.");
window.setTimeout(() => setNotice(""), 2500);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
function updateToggle(field, value) {
setSettings((current) => ({ ...current, [field]: value }));
savePatch({ [field]: value });
}
function updateTimeout(value) {
const minutes = Number(value);
setSettings((current) => ({ ...current, session_timeout_minutes: minutes }));
savePatch({ session_timeout_minutes: minutes });
}
return (
<section className="company-people company-security">
<header className="company-people-head">
<div>
<h1>Security</h1>
<p>Protect your organization and manage access.</p>
</div>
</header>
{error && <p className="form-error" role="alert">{error}</p>}
{notice && <p className="security-notice" role="status">{notice}</p>}
{loading || !settings ? (
<p className="people-table-empty">Loading security settings…</p>
) : (
<>
<div className="security-panel">
<h2>Authentication</h2>
<ToggleRow
title="Require strong passwords"
description="Minimum 8 characters with numbers and symbols."
checked={settings.require_strong_passwords}
disabled={saving}
onChange={(value) => updateToggle("require_strong_passwords", value)}
/>
<ToggleRow
title="Require MFA for administrators"
description="Recommended for better security."
checked={settings.require_mfa_admins}
disabled={saving}
onChange={(value) => updateToggle("require_mfa_admins", value)}
/>
<ToggleRow
title="Require MFA for all users"
description="Users will be prompted to set up MFA on first login."
checked={settings.require_mfa_all_users}
disabled={saving}
onChange={(value) => updateToggle("require_mfa_all_users", value)}
/>
</div>
<div className="security-panel">
<h2>Session timeout</h2>
<div className="security-setting-row">
<div>
<strong>Automatically sign out inactive users</strong>
<p>Users are signed out after a period of inactivity.</p>
</div>
<select
className="security-timeout-select"
value={settings.session_timeout_minutes}
disabled={saving}
onChange={(event) => updateTimeout(event.target.value)}
>
{SESSION_TIMEOUTS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</>
)}
</section>
);
}

View file

@ -29,9 +29,11 @@ import { getCurrentAccount, getMyCompanyDomain } from "../api";
import Brand from "./Brand"; import Brand from "./Brand";
import CompanyDnsSetup from "./CompanyDnsSetup"; import CompanyDnsSetup from "./CompanyDnsSetup";
import CompanyDomains from "./CompanyDomains"; import CompanyDomains from "./CompanyDomains";
import CompanyAdministrators from "./CompanyAdministrators";
import CompanyAliases from "./CompanyAliases"; import CompanyAliases from "./CompanyAliases";
import CompanyGroups from "./CompanyGroups"; import CompanyGroups from "./CompanyGroups";
import CompanyPeople from "./CompanyPeople"; import CompanyPeople from "./CompanyPeople";
import CompanySecurity from "./CompanySecurity";
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup"; import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
import ComposeModal from "./ComposeModal"; import ComposeModal from "./ComposeModal";
import MailboxView from "./MailboxView"; import MailboxView from "./MailboxView";
@ -62,12 +64,15 @@ const agentItems = [
[Plug, "Integrations", "integrations"], [Plug, "Integrations", "integrations"],
]; ];
const companyItems = [ const companyNavItems = [
[Home, "Overview"], [Home, "Overview"],
[Users, "People"], [Users, "People"],
[UsersRound, "Groups"], [UsersRound, "Groups"],
[Globe, "Domains"], [Globe, "Domains"],
[CircleEllipsis, "Aliases"], [CircleEllipsis, "Aliases"],
];
const manageNavItems = [
[Shield, "Security"], [Shield, "Security"],
[Crown, "Administrators"], [Crown, "Administrators"],
[CircleDollarSign, "Billing"], [CircleDollarSign, "Billing"],
@ -244,6 +249,7 @@ export default function DashboardPage() {
? companyDomain.domain_name ? companyDomain.domain_name
: "Personal workspace"; : "Personal workspace";
const isDomainOwner = account.account_type === "company" && account.is_domain_owner; const isDomainOwner = account.account_type === "company" && account.is_domain_owner;
const isOrgOnboarding = isDomainOwner && !isOrgSetupComplete(account.id);
const mailActive = lens === "starred" const mailActive = lens === "starred"
? "Starred" ? "Starred"
: active === "Outbox" : active === "Outbox"
@ -268,54 +274,76 @@ export default function DashboardPage() {
<button aria-label="Close navigation" type="button" onClick={() => setMenuOpen(false)}><X size={19} /></button> <button aria-label="Close navigation" type="button" onClick={() => setMenuOpen(false)}><X size={19} /></button>
</div> </div>
<div className="dashboard-compose-wrap"> {!isOrgOnboarding && (
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}> <div className="dashboard-compose-wrap">
<span className="dashboard-compose-main"> <button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
<Mail size={16} strokeWidth={1.75} /> <span className="dashboard-compose-main">
<span>Compose</span> <Mail size={16} strokeWidth={1.75} />
</span> <span>Compose</span>
</button> </span>
<button className="dashboard-compose-menu" type="button" aria-label="Compose options"> </button>
<ChevronDown size={14} strokeWidth={1.75} /> <button className="dashboard-compose-menu" type="button" aria-label="Compose options">
</button> <ChevronDown size={14} strokeWidth={1.75} />
</div> </button>
</div>
)}
<div className="dashboard-sidebar-scroll"> <div className="dashboard-sidebar-scroll">
{isDomainOwner && ( {isDomainOwner && (
<SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first"> <>
<nav aria-label="Company"> <SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first">
{companyItems.map(([ItemIcon, label]) => ( <nav aria-label="Company">
{companyNavItems.map(([ItemIcon, label]) => (
<button
className={`dashboard-nav-item dashboard-company-item${companyActive === label ? " active" : ""}`}
key={label}
type="button"
onClick={() => selectCompanyItem(label)}
>
<ItemIcon size={16} strokeWidth={1.75} />
<span>{label}</span>
</button>
))}
</nav>
</SidebarSection>
<SidebarSection title="Manage" className="dashboard-sidebar-manage">
<nav aria-label="Manage">
{manageNavItems.map(([ItemIcon, label]) => (
<button
className={`dashboard-nav-item dashboard-company-item${companyActive === label ? " active" : ""}`}
key={label}
type="button"
onClick={() => selectCompanyItem(label)}
>
<ItemIcon size={16} strokeWidth={1.75} />
<span>{label}</span>
</button>
))}
</nav>
</SidebarSection>
</>
)}
{!isOrgOnboarding && (
<SidebarSection title="Mail" className="dashboard-sidebar-mail">
<nav aria-label="Mail">
{mailItems.map(([ItemIcon, label, countKey]) => (
<button <button
className={`dashboard-nav-item dashboard-company-item${companyActive === label ? " active" : ""}`} className={`dashboard-nav-item${!companyActive && mailActive === label ? " active" : ""}`}
key={label} key={label}
type="button" type="button"
onClick={() => selectCompanyItem(label)} onClick={() => selectMailItem(label)}
> >
<ItemIcon size={16} strokeWidth={1.75} /> <ItemIcon size={16} strokeWidth={1.75} />
<span>{label}</span> <span>{label}</span>
<NavBadge value={countKey ? mailStats[countKey] : 0} />
</button> </button>
))} ))}
</nav> </nav>
</SidebarSection> </SidebarSection>
)} )}
<SidebarSection title="Mail" className="dashboard-sidebar-mail"> {!isOrgOnboarding && (
<nav aria-label="Mail">
{mailItems.map(([ItemIcon, label, countKey]) => (
<button
className={`dashboard-nav-item${!companyActive && mailActive === label ? " active" : ""}`}
key={label}
type="button"
onClick={() => selectMailItem(label)}
>
<ItemIcon size={16} strokeWidth={1.75} />
<span>{label}</span>
<NavBadge value={countKey ? mailStats[countKey] : 0} />
</button>
))}
</nav>
</SidebarSection>
<SidebarSection title="Labels" className="dashboard-sidebar-labels"> <SidebarSection title="Labels" className="dashboard-sidebar-labels">
<nav aria-label="Labels"> <nav aria-label="Labels">
{labels.map(([name, tone, countKey]) => ( {labels.map(([name, tone, countKey]) => (
@ -340,7 +368,9 @@ export default function DashboardPage() {
</button> </button>
</nav> </nav>
</SidebarSection> </SidebarSection>
)}
{!isOrgOnboarding && (
<SidebarSection title="Agent" className="dashboard-sidebar-agent"> <SidebarSection title="Agent" className="dashboard-sidebar-agent">
<nav aria-label="Agent"> <nav aria-label="Agent">
{agentItems.map(([ItemIcon, label, actionKey]) => ( {agentItems.map(([ItemIcon, label, actionKey]) => (
@ -357,6 +387,7 @@ export default function DashboardPage() {
))} ))}
</nav> </nav>
</SidebarSection> </SidebarSection>
)}
</div> </div>
<div className="dashboard-profile"> <div className="dashboard-profile">
@ -367,7 +398,7 @@ export default function DashboardPage() {
</aside> </aside>
<main className="dashboard-content"> <main className="dashboard-content">
{companyActive && !["Overview", "Domains", "People", "Groups", "Aliases"].includes(companyActive) && ( {companyActive && !["Overview", "Domains", "People", "Groups", "Aliases", "Security", "Administrators"].includes(companyActive) && (
<div className="dashboard-content-heading"> <div className="dashboard-content-heading">
<div><p>{workspaceTitle}</p><h1>{companyActive}</h1></div> <div><p>{workspaceTitle}</p><h1>{companyActive}</h1></div>
<span className="dashboard-account-pill">{workspaceLabel}</span> <span className="dashboard-account-pill">{workspaceLabel}</span>
@ -437,7 +468,13 @@ export default function DashboardPage() {
{companyActive === "Aliases" && isDomainOwner && ( {companyActive === "Aliases" && isDomainOwner && (
<CompanyAliases account={account} /> <CompanyAliases account={account} />
)} )}
{companyActive && !["Overview", "Domains", "People", "Groups", "Aliases"].includes(companyActive) && ( {companyActive === "Security" && isDomainOwner && (
<CompanySecurity />
)}
{companyActive === "Administrators" && isDomainOwner && (
<CompanyAdministrators account={account} />
)}
{companyActive && !["Overview", "Domains", "People", "Groups", "Aliases", "Security", "Administrators"].includes(companyActive) && (
<section className="dashboard-empty dashboard-company-view"> <section className="dashboard-empty dashboard-company-view">
<span><Sparkles size={24} /></span> <span><Sparkles size={24} /></span>
<h2>{companyActive}</h2> <h2>{companyActive}</h2>

View file

@ -179,37 +179,58 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.button-purple { min-height:44px; padding:0 20px; display:inline-flex; align-items:center; gap:8px; border:0; border-radius:10px; background:#7357F6; color:#fff; font-size:13px; font-weight:600; cursor:pointer; box-shadow:0 8px 20px rgba(115,87,246,0.28); } .button-purple { min-height:44px; padding:0 20px; display:inline-flex; align-items:center; gap:8px; border:0; border-radius:10px; background:#7357F6; color:#fff; font-size:13px; font-weight:600; cursor:pointer; box-shadow:0 8px 20px rgba(115,87,246,0.28); }
.button-purple:hover { filter:brightness(1.03); } .button-purple:hover { filter:brightness(1.03); }
.org-setup { padding:8px 0 24px; } .org-setup { padding:8px 0 24px; }
.org-setup-head { margin-bottom:28px; } .org-setup-head { margin-bottom:24px; }
.org-setup-head h1 { margin:0 0 8px; font:700 clamp(28px,3vw,36px)/1.15 "DM Sans"; letter-spacing:-.6px; color:#1a1628; } .org-setup-head h1 { margin:0 0 8px; font:700 31px/1.15 "DM Sans"; letter-spacing:-.5px; color:#1a1628; }
.org-setup-head p { margin:0; color:#77758A; font-size:14px; } .org-setup-head p { margin:0; color:#77758A; font-size:14px; line-height:1.5; }
.org-setup-layout { display:grid; grid-template-columns:minmax(220px,280px) minmax(0,1fr); gap:28px; align-items:start; } .org-setup-layout { display:grid; grid-template-columns:minmax(260px,280px) minmax(0,1fr); gap:32px; align-items:start; }
.org-setup-steps { margin:0; padding:0; list-style:none; display:grid; gap:0; } .org-setup-steps { margin:0; padding:0; list-style:none; display:grid; gap:0; }
.org-setup-step { padding:12px 14px; display:grid; grid-template-columns:32px 1fr; gap:12px; align-items:start; border-radius:12px; color:#6f6c7b; } .org-setup-step { padding:10px 12px 10px 8px; display:grid; grid-template-columns:32px 1fr; gap:12px; align-items:stretch; border-radius:12px; color:#8d8792; }
.org-setup-step-mark { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; border:1px solid #E4E1E8; background:#fff; font-size:13px; font-weight:600; color:#6f6c7b; } .org-setup-step-indicator { display:flex; flex-direction:column; align-items:center; min-height:100%; }
.org-setup-step-copy { display:grid; gap:2px; } .org-setup-step-mark { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; border:1px solid #D9D5E0; background:#fff; font-size:12px; font-weight:600; color:#9b96a3; flex-shrink:0; }
.org-setup-step-copy strong { font-size:13px; font-weight:600; color:#3d3948; } .org-setup-step-connector { width:1px; flex:1; min-height:18px; margin:6px 0; background:#E8E4F0; }
.org-setup-step-copy small { font-size:11px; color:#8d8792; line-height:1.35; } .org-setup-step-copy { display:grid; gap:3px; padding-top:4px; }
.org-setup-step.complete .org-setup-step-mark { border-color:#7357F6; background:#7357F6; color:#fff; } .org-setup-step-copy strong { font-size:13px; font-weight:600; color:#8d8792; line-height:1.3; }
.org-setup-step.complete .org-setup-step-copy strong { color:#5a4fcf; } .org-setup-step-copy small { font-size:11px; font-weight:400; color:#a8a3ae; line-height:1.35; }
.org-setup-step.complete .org-setup-step-mark { border-color:#B9A8F8; background:#9B84F2; color:#fff; }
.org-setup-step.complete .org-setup-step-connector { background:#D8CCFF; }
.org-setup-step.complete .org-setup-step-copy strong { color:#8d8792; font-weight:500; }
.org-setup-step.complete .org-setup-step-copy small { color:#a8a3ae; }
.org-setup-step.active { background:#F6F2FF; } .org-setup-step.active { background:#F6F2FF; }
.org-setup-step.active .org-setup-step-mark { border-color:#7357F6; background:#7357F6; color:#fff; } .org-setup-step.active .org-setup-step-mark { border-color:#7357F6; background:#7357F6; color:#fff; box-shadow:0 0 0 4px rgba(115,87,246,0.10); }
.org-setup-step.active .org-setup-step-copy strong { color:#5a4fcf; } .org-setup-step.active .org-setup-step-copy strong { color:#1a1628; font-weight:600; }
.org-setup-panel { padding:28px 32px; border:1px solid #ECE9F2; border-radius:16px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,0.04); } .org-setup-step.active .org-setup-step-copy small { color:#6f6c7b; }
.org-setup-panel h2 { margin:0 0 18px; font:700 22px/1.2 "DM Sans"; color:#1a1628; } .org-setup-step:not(.complete):not(.active) .org-setup-step-mark { border-color:#E4E1E8; background:transparent; color:#b4afba; }
.org-setup-section-label { margin:0 0 16px; font-size:12px; font-weight:600; color:#8d8792; } .org-setup-step:not(.complete):not(.active) .org-setup-step-copy strong { color:#9b96a3; font-weight:500; }
.org-setup-panel { padding:28px 32px; border:1px solid rgba(20,20,30,0.055); border-radius:16px; background:#fff; box-shadow:0 1px 2px rgba(20,18,30,0.03),0 10px 30px rgba(20,18,30,0.04); }
.org-setup-panel-intro { margin-bottom:24px; }
.org-setup-panel h2 { margin:0 0 6px; font:700 19px/1.25 "DM Sans"; color:#1a1628; }
.org-setup-section-label { margin:0; font-size:11px; font-weight:400; color:#8d8792; line-height:1.45; }
.org-setup-form-shell { max-width:720px; display:grid; gap:16px; }
.org-setup-lead { margin:0 0 20px; color:#6f6c7b; font-size:14px; line-height:1.5; } .org-setup-lead { margin:0 0 20px; color:#6f6c7b; font-size:14px; line-height:1.5; }
.org-setup-form { display:grid; gap:14px; } .org-setup-form { margin:0; }
.org-setup-form label { display:grid; gap:6px; font-size:12px; font-weight:600; color:#4a4652; } .org-setup-field { display:grid; gap:6px; }
.org-setup-form label span { color:#e15b45; } .org-setup-label-text { display:inline-flex; align-items:center; gap:3px; font-size:12px; font-weight:600; color:#4a4652; line-height:1.2; white-space:nowrap; }
.org-setup-form input,.org-setup-form select { height:42px; padding:0 12px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; font-size:13px; color:#1a1628; } .org-setup-required { color:#e15b45; font-weight:700; }
.org-setup-form input:focus,.org-setup-form select:focus { outline:none; border-color:#C8B8FF; box-shadow:0 0 0 3px rgba(115,87,246,0.12); } .org-setup-form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.org-setup-form input,.org-setup-form select { width:100%; height:44px; padding:0 14px; border:1px solid #E2E0E8; border-radius:10px; background:#fff; font-size:13px; color:#1a1628; transition:border-color .15s,box-shadow .15s; }
.org-setup-form input:focus,.org-setup-form select:focus { outline:none; border-color:#7357F6; box-shadow:0 0 0 3px rgba(115,87,246,0.10); }
.org-setup-field.has-error input,.org-setup-field.has-error select,.org-setup-field.has-error .org-setup-email-field input { border-color:#e15b45; box-shadow:none; }
.org-setup-field-error { color:#c44b38; font-size:11px; font-weight:400; line-height:1.35; }
.org-setup-field-hint { margin-top:-2px; color:#8d8792; font-size:11px; font-weight:400; line-height:1.35; }
.org-setup-email-field { display:grid; grid-template-columns:1fr auto; gap:10px; align-items:center; }
.org-setup-email-field span { color:#8d8792; font-size:13px; font-weight:600; white-space:nowrap; }
.org-setup-actions { margin-top:8px; display:flex; justify-content:flex-end; } .org-setup-actions { margin-top:8px; display:flex; justify-content:flex-end; }
.org-setup-submit { min-height:42px; padding:0 20px; }
.org-setup-submit:disabled { opacity:.48; cursor:not-allowed; box-shadow:none; }
.org-setup-placeholder { margin-bottom:20px; padding:24px; border:1px dashed #E4E1E8; border-radius:12px; background:#FAFAFC; color:#8d8792; font-size:13px; text-align:center; } .org-setup-placeholder { margin-bottom:20px; padding:24px; border:1px dashed #E4E1E8; border-radius:12px; background:#FAFAFC; color:#8d8792; font-size:13px; text-align:center; }
.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; }
.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-layout { grid-template-columns:1fr; } .org-setup-layout { grid-template-columns:1fr; }
.org-setup-form-row { grid-template-columns:1fr; }
} }
.company-domains,.company-domains-add,.company-domain-detail { padding:8px 0 24px; } .company-domains,.company-domains-add,.company-domain-detail { padding:8px 0 24px; }
@ -348,6 +369,24 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.org-member-picker-empty { margin:0; color:#8d8792; font-size:12px; } .org-member-picker-empty { margin:0; color:#8d8792; font-size:12px; }
.org-member-picker-row { display:grid; grid-template-columns:auto 1fr; gap:4px 10px; align-items:center; font-size:13px; color:#3d3948; cursor:pointer; } .org-member-picker-row { display:grid; grid-template-columns:auto 1fr; gap:4px 10px; align-items:center; font-size:13px; color:#3d3948; cursor:pointer; }
.org-member-picker-row small { grid-column:2; color:#8d8792; font-size:11px; } .org-member-picker-row small { grid-column:2; color:#8d8792; font-size:11px; }
.company-security .security-panel { margin-bottom:16px; padding:24px 28px; border:1px solid #ECE9F2; border-radius:16px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,0.04); }
.company-security .security-panel h2 { margin:0 0 18px; font:700 18px/1.2 "DM Sans"; color:#1a1628; }
.security-setting-row { padding:14px 0; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; border-top:1px solid #F0EEF5; }
.security-setting-row:first-of-type { border-top:0; padding-top:0; }
.security-setting-row strong { display:block; margin-bottom:4px; font-size:14px; color:#1a1628; }
.security-setting-row p { margin:0; color:#8d8792; font-size:12px; line-height:1.45; }
.security-toggle { position:relative; width:44px; height:24px; flex-shrink:0; }
.security-toggle input { position:absolute; opacity:0; width:0; height:0; }
.security-toggle span { position:absolute; inset:0; border-radius:999px; background:#D9D5E0; transition:background .2s; cursor:pointer; }
.security-toggle span::after { content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(20,18,30,0.18); transition:transform .2s; }
.security-toggle input:checked + span { background:#7357F6; }
.security-toggle input:checked + span::after { transform:translateX(20px); }
.security-toggle input:disabled + span { opacity:.55; cursor:not-allowed; }
.security-timeout-select,.admin-role-select { min-width:140px; height:40px; padding:0 12px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; font-size:13px; color:#4a4652; }
.security-notice { margin:0 0 12px; color:#2f6b43; font-size:13px; font-weight:600; }
.admin-owner-badge { margin-left:8px; display:inline-flex; align-items:center; gap:4px; padding:2px 8px; border-radius:999px; background:#FFF4E5; color:#c4842d; font-size:10px; font-weight:700; font-style:normal; text-transform:uppercase; letter-spacing:.3px; }
.admin-role-readonly { color:#6f6c7b; font-size:13px; font-weight:600; }
.admin-role-hint { margin-top:16px; color:#8d8792; font-size:12px; line-height:1.5; }
.dns-copy { flex-shrink:0; border:0; border-radius:7px; padding:4px; background:#f7f3f1; color:#8c8790; cursor:pointer; } .dns-copy { flex-shrink:0; border:0; border-radius:7px; padding:4px; background:#f7f3f1; color:#8c8790; cursor:pointer; }
.dns-copy:hover { background:#fff0eb; color:var(--coral); } .dns-copy:hover { background:#fff0eb; color:var(--coral); }