ui enhancement company
This commit is contained in:
parent
cc3f3f3de5
commit
c01914736b
14 changed files with 876 additions and 159 deletions
24
backend/app/deps/domain_owner.py
Normal file
24
backend/app/deps/domain_owner.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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__ = (
|
||||||
|
|
|
||||||
39
backend/app/routers/org.py
Normal file
39
backend/app/routers/org.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
55
backend/app/services/org_security.py
Normal file
55
backend/app/services/org_security.py
Normal 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)
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
158
frontend/src/components/CompanyAdministrators.jsx
Normal file
158
frontend/src/components/CompanyAdministrators.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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're ready to use AGMail.
|
Domain <strong>{companyDomainName}</strong> is connected. You'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>
|
||||||
|
|
|
||||||
151
frontend/src/components/CompanySecurity.jsx
Normal file
151
frontend/src/components/CompanySecurity.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue