Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
99a6f42
feat(roles): add ROLES single source-of-truth + Role type
important-new Jun 13, 2026
f8419c1
refactor(rbac): type requireRole to Role[], drop alias shim, migrate …
important-new Jun 13, 2026
b142cb6
refactor(team): remove guest invite subsystem end-to-end
important-new Jun 13, 2026
586c75c
refactor(seat): all members count equally after guest removal
important-new Jun 13, 2026
1f627e2
refactor(team): remove apprentice review-queue subsystem (apprentices…
important-new Jun 13, 2026
16ee811
refactor(roles): collapse role type + invite enum to the 4 canonical …
important-new Jun 13, 2026
f60113a
test+lint(roles): drift gate + ban bare role string literals outside …
important-new Jun 13, 2026
4706714
feat(auth): getCapabilities resolver (role template + overrides)
important-new Jun 13, 2026
4f35a98
feat(db): add users.permission_overrides column + migration
important-new Jun 13, 2026
db7456f
feat(auth): requireCapability middleware on publish/financial/schedul…
important-new Jun 13, 2026
58a4d98
fix(auth): allow inspector through role gate on financial/contacts so…
important-new Jun 13, 2026
b2a65ac
refactor(roles): rename admin -> manager (DB value == UI label)
important-new Jun 13, 2026
480ba95
test(workers): sync inline users DDL with permission_overrides column
important-new Jun 13, 2026
ce9f3c7
feat(db): remap legacy role values (admin->manager, collapse subsyste…
important-new Jun 13, 2026
dc6f22d
feat(team): advanced permission toggles on invite + carry overrides t…
important-new Jun 13, 2026
3d7a52d
docs: Spectora/ISN -> OpenInspection role migration guide
important-new Jun 13, 2026
48a0312
fix(billing-ui): remove stale guest stat cards + cost row after guest…
important-new Jun 13, 2026
8d3f704
fix(billing-ui): drop stale guest-invite help copy after guest removal
important-new Jun 13, 2026
70c4554
Merge origin/main (#143) into role-permission-templates; renumber mig…
important-new Jun 14, 2026
dc45671
test: fix DB-16 attached-photo test role admin->manager (merge fallout)
important-new Jun 14, 2026
8472df1
fix(team-ui): replace stale role legend with the 4 canonical roles (E…
important-new Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions app/components/WorkflowChip.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
type WorkflowState =
| "agreement"
| "payment"
| "apprentice-review"
| "published"
| "cancelled"
| "draft";

const STATE_LABELS: Record<WorkflowState, string> = {
agreement: "Agreement",
payment: "Payment",
"apprentice-review": "Apprentice review",
published: "Published",
cancelled: "Cancelled",
draft: "Draft",
Expand All @@ -18,7 +16,6 @@ const STATE_LABELS: Record<WorkflowState, string> = {
const STATE_TONES: Record<WorkflowState, { bg: string; text: string }> = {
agreement: { bg: "bg-ih-watch-bg", text: "text-ih-watch-fg" },
payment: { bg: "bg-ih-info-bg", text: "text-ih-info-fg" },
"apprentice-review": { bg: "bg-ih-watch-bg", text: "text-ih-watch-fg" },
published: { bg: "bg-ih-ok-bg", text: "text-ih-ok-fg" },
cancelled: { bg: "bg-ih-bad-bg", text: "text-ih-bad-fg" },
draft: { bg: "bg-ih-bg-muted", text: "text-ih-fg-3" },
Expand Down
166 changes: 65 additions & 101 deletions app/components/modals/InviteSeatModal.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,58 @@
import { useState, useEffect } from "react";
import { useFetcher } from "react-router";
import { getCapabilities, TOGGLEABLE, type Capability, type CapabilitySet, type PermissionOverrides } from "../../../server/lib/auth/capabilities";

type Mode = "permanent" | "guest";
type Role = "lead" | "specialist" | "apprentice" | "office";
type Role = "owner" | "manager" | "inspector" | "agent";

const ROLE_DESC: Record<Role, string> = {
lead: "Full access to inspections, templates, and team management.",
specialist: "Access to assigned sections only.",
apprentice: "Supervised access — requires mentor approval before publishing.",
office: "Dashboard, scheduling, and billing. No inspection editing.",
owner: "Full access, including billing and ownership transfer.",
manager: "Full access to inspections, templates, and team management.",
inspector: "Create and edit inspections they're assigned to.",
agent: "Read-only buyer-agent view.",
};

const DURATIONS = [
{ seconds: 86400, label: "1 day", price: "$1.49" },
{ seconds: 259200, label: "3 days", price: "$4.47" },
{ seconds: 604800, label: "7 days", price: "$10.43" },
] as const;
/** Advanced-permissions toggle labels, in TOGGLEABLE order. */
export const CAP_LABELS: Record<Capability, string> = {
publish: "Publish reports",
scheduleOthers: "Schedule for others",
financial: "Financial data",
manageContacts: "Manage contacts",
};

/**
* Reduce the edited capability set to only the toggles that differ from the
* role's template default (happy-dom has no render harness, so the submit
* logic lives here and is unit-tested directly — see invite-overrides.spec).
*/
export function computeOverrideDiff(role: Role, caps: CapabilitySet): PermissionOverrides {
const template = getCapabilities(role, null);
const diff: PermissionOverrides = {};
for (const cap of TOGGLEABLE) {
if (caps[cap] !== template[cap]) diff[cap] = caps[cap];
}
return diff;
}

interface InviteSeatModalProps {
open: boolean;
onClose: () => void;
leads?: Array<{ id: string; email: string }>;
sections?: Array<{ id: string; name: string }>;
}

export function InviteSeatModal({ open, onClose, leads = [], sections = [] }: InviteSeatModalProps) {
const [mode, setMode] = useState<Mode>("permanent");
export function InviteSeatModal({ open, onClose }: InviteSeatModalProps) {
const [email, setEmail] = useState("");
const [notify, setNotify] = useState(true);
const [role, setRole] = useState<Role>("lead");
const [mentorId, setMentorId] = useState("");
const [sectionIds, setSectionIds] = useState<string[]>([]);
const [durationSeconds, setDurationSeconds] = useState(86400);
const [generatedUrl, setGeneratedUrl] = useState("");
const [role, setRole] = useState<Role>("inspector");
const [advancedOpen, setAdvancedOpen] = useState(false);
// Effective capability set the toggles render. Re-derived from the role
// template whenever the role changes (so the disclosure always shows the
// selected role's defaults until the inviter edits them).
const [caps, setCaps] = useState(() => getCapabilities("inspector", null));
const [error, setError] = useState("");

useEffect(() => {
setCaps(getCapabilities(role, null));
}, [role]);

const inviteFetcher = useFetcher<{ ok: boolean; intent?: string | null; error: string | null; url: string | null }>();
const submitting = inviteFetcher.state !== "idle";

Expand All @@ -47,36 +65,22 @@ export function InviteSeatModal({ open, onClose, leads = [], sections = [] }: In
}
if (d.intent === "invite") {
onClose();
} else if (d.intent === "guest-invite" && d.url) {
setGeneratedUrl(d.url);
}
}, [inviteFetcher.data, onClose]);

if (!open) return null;

function toggleSection(id: string) {
setSectionIds((prev) => prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]);
}

function submitPermanent() {
if (submitting) return;
setError("");
// Only send capabilities that differ from the role template; the server
// re-diffs and stores null when nothing differs.
const diff = computeOverrideDiff(role, caps);
const fd = new FormData();
fd.append("intent", "invite");
fd.append("email", email);
fd.append("role", role);
if (mentorId) fd.append("mentorId", mentorId);
if (sectionIds.length > 0) fd.append("assignedSectionIds", JSON.stringify(sectionIds));
inviteFetcher.submit(fd, { method: "POST", action: "/resources/team-members" });
}

function submitGuest() {
if (submitting) return;
setError("");
const fd = new FormData();
fd.append("intent", "guest-invite");
fd.append("role", role);
fd.append("durationSeconds", String(durationSeconds));
if (Object.keys(diff).length > 0) fd.append("permissionOverrides", JSON.stringify(diff));
inviteFetcher.submit(fd, { method: "POST", action: "/resources/team-members" });
}

Expand All @@ -85,17 +89,9 @@ export function InviteSeatModal({ open, onClose, leads = [], sections = [] }: In
<div className="max-w-md w-full bg-ih-bg-card rounded-xl shadow-ih-popover" onClick={(e) => e.stopPropagation()}>
<header className="px-6 py-4 border-b border-ih-border flex items-center gap-4">
<h2 className="text-lg font-bold flex-1 text-ih-fg-1">Invite</h2>
<div className="flex gap-1">
{(["permanent", "guest"] as const).map((m) => (
<button key={m} onClick={() => setMode(m)} className={`px-3 py-1 rounded-md text-sm font-semibold transition-colors ${mode === m ? "bg-ih-primary text-white" : "text-ih-fg-3 hover:bg-ih-bg-muted"}`}>
{m.charAt(0).toUpperCase() + m.slice(1)}
</button>
))}
</div>
</header>

<div className="p-6 space-y-4">
{mode === "permanent" && (
<div className="space-y-3">
<label className="block">
<span className="block text-[10px] font-bold uppercase tracking-widest text-ih-fg-3 mb-1">Email</span>
Expand All @@ -106,81 +102,49 @@ export function InviteSeatModal({ open, onClose, leads = [], sections = [] }: In
Send email notification
</label>
</div>
)}

<label className="block">
<span className="block text-[10px] font-bold uppercase tracking-widest text-ih-fg-3 mb-1">Role</span>
<select className="w-full px-3 py-2 rounded-md border border-ih-border bg-ih-bg-card text-sm text-ih-fg-1" value={role} onChange={(e) => setRole(e.target.value as Role)}>
<option value="lead">Lead inspector</option>
<option value="specialist">Specialist</option>
<option value="apprentice">Apprentice</option>
<option value="office">Office staff</option>
<option value="manager">Manager</option>
<option value="inspector">Inspector</option>
<option value="agent">Agent</option>
</select>
</label>
<p className="text-xs text-ih-fg-3">{ROLE_DESC[role]}</p>

{role === "apprentice" && (
<label className="block">
<span className="block text-[10px] font-bold uppercase tracking-widest text-ih-fg-3 mb-1">Mentor</span>
<select className="w-full px-3 py-2 rounded-md border border-ih-border bg-ih-bg-card text-sm text-ih-fg-1" value={mentorId} onChange={(e) => setMentorId(e.target.value)}>
<option value="">Select a lead inspector...</option>
{leads.map((m) => <option key={m.id} value={m.id}>{m.email}</option>)}
</select>
</label>
)}

{role === "specialist" && (
<div>
<span className="block text-[10px] font-bold uppercase tracking-widest text-ih-fg-3 mb-1">Assigned sections</span>
<div className="p-3 max-h-40 overflow-y-auto space-y-1 bg-ih-bg-muted rounded-md border border-ih-border">
{sections.length === 0 ? (
<p className="text-xs text-ih-fg-4">No template sections loaded yet.</p>
) : sections.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-sm text-ih-fg-3">
<input type="checkbox" checked={sectionIds.includes(s.id)} onChange={() => toggleSection(s.id)} />
<span>{s.name}</span>
<div className="border-t border-ih-border pt-3">
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
aria-expanded={advancedOpen}
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-ih-fg-3 hover:text-ih-fg-1"
>
<span className={`transition-transform ${advancedOpen ? "rotate-90" : ""}`} aria-hidden="true">▸</span>
Advanced permissions
</button>
{advancedOpen && (
<div className="mt-3 space-y-2">
{TOGGLEABLE.map((cap) => (
<label key={cap} className="flex items-center gap-2 text-sm text-ih-fg-3">
<input
type="checkbox"
checked={caps[cap]}
onChange={(e) => setCaps((prev) => ({ ...prev, [cap]: e.target.checked }))}
/>
{CAP_LABELS[cap]}
</label>
))}
</div>
</div>
)}

{mode === "guest" && (
<>
<div>
<span className="block text-[10px] font-bold uppercase tracking-widest text-ih-fg-3 mb-1">Duration</span>
<div className="flex gap-2 flex-wrap">
{DURATIONS.map((d) => (
<label key={d.seconds} className="flex items-center gap-1 text-sm text-ih-fg-3">
<input type="radio" checked={durationSeconds === d.seconds} onChange={() => setDurationSeconds(d.seconds)} />
<span>{d.label} <span className="text-xs text-ih-fg-4">{d.price}</span></span>
</label>
))}
</div>
<p className="text-xs text-ih-fg-3 mt-2">Guest counts against your team's seat quota while active.</p>
</div>

{generatedUrl && (
<div className="p-3 bg-ih-ok-bg border border-ih-ok rounded-md">
<div className="text-[10px] font-bold uppercase text-ih-ok-fg mb-1">Invite link (one-time)</div>
<input className="w-full px-2 py-1 text-xs rounded border border-ih-border bg-ih-bg-card text-ih-fg-1" readOnly value={generatedUrl} />
<button className="mt-2 px-3 py-1 text-xs font-semibold rounded bg-ih-bg-card border border-ih-border text-ih-fg-3" onClick={() => navigator.clipboard.writeText(generatedUrl)}>Copy link</button>
</div>
)}
</>
)}

{error && <p className="text-xs text-ih-bad-fg font-semibold">{error}</p>}
</div>

<footer className="px-6 py-4 border-t border-ih-border flex justify-end gap-2">
<button onClick={onClose} className="px-4 h-10 rounded-xl border border-ih-border text-sm font-semibold text-ih-fg-3 hover:bg-ih-bg-muted">Cancel</button>
{mode === "permanent" && (
<button onClick={submitPermanent} disabled={submitting} className="px-4 h-10 rounded-xl bg-ih-primary text-white text-sm font-semibold hover:bg-ih-primary-600 disabled:opacity-50">Send invite</button>
)}
{mode === "guest" && !generatedUrl && (
<button onClick={submitGuest} disabled={submitting} className="px-4 h-10 rounded-xl bg-ih-primary text-white text-sm font-semibold hover:bg-ih-primary-600 disabled:opacity-50">Generate link</button>
)}
</footer>
</div>
</div>
Expand Down
4 changes: 1 addition & 3 deletions app/components/team/RosterPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ interface RosterPopoverProps {
roster: RosterMember[];
onClose: () => void;
onInvitePermanent?: () => void;
onInviteGuest?: () => void;
}

export function RosterPopover({ open, roster, onClose, onInvitePermanent, onInviteGuest }: RosterPopoverProps) {
export function RosterPopover({ open, roster, onClose, onInvitePermanent }: RosterPopoverProps) {
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -59,7 +58,6 @@ export function RosterPopover({ open, roster, onClose, onInvitePermanent, onInvi

<div className="mt-4 pt-3 border-t border-ih-border flex gap-2">
<button type="button" className="ih-btn ih-btn--sm ih-btn--secondary" onClick={onInvitePermanent} title="Send an email invite to a new permanent inspector">Add inspector</button>
<button type="button" className="ih-btn ih-btn--sm ih-btn--secondary" onClick={onInviteGuest} title="Generate a one-time guest invite link">Invite guest</button>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/components/team/TeamBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function TeamBanner({ show, members, onManage }: TeamBannerProps) {
<span className="ih-eyebrow text-ih-primary">Team mode</span>
<div className="flex -space-x-1.5">
{members.map((m) => (
<div key={m.id} className="w-7 h-7 rounded-full ring-2 ring-ih-bg-card bg-ih-bg-muted flex items-center justify-center text-xs font-bold text-ih-fg-2" title={`${m.name || m.id}${m.role === "lead" ? " (lead)" : ""}`}>
<div key={m.id} className="w-7 h-7 rounded-full ring-2 ring-ih-bg-card bg-ih-bg-muted flex items-center justify-center text-xs font-bold text-ih-fg-2" title={m.name || m.id}>
{(m.name || m.id || "?").slice(0, 2).toUpperCase()}
</div>
))}
Expand Down
4 changes: 0 additions & 4 deletions app/lib/api-client.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import type {
EventsApi,
EmailTemplatesApi,
EvidenceApi,
GuestApi,
IdentityApi,
InspectionPrefsApi,
InspectionRequestsApi,
Expand Down Expand Up @@ -127,7 +126,6 @@ export interface Api {
events: ReturnType<typeof hc<EventsApi>>;
emailTemplates: ReturnType<typeof hc<EmailTemplatesApi>>;
evidence: ReturnType<typeof hc<EvidenceApi>>;
guest: ReturnType<typeof hc<GuestApi>>;
identity: ReturnType<typeof hc<IdentityApi>>;
inspectionPrefs: ReturnType<typeof hc<InspectionPrefsApi>>;
inspectionRequests: ReturnType<typeof hc<InspectionRequestsApi>>;
Expand Down Expand Up @@ -191,7 +189,6 @@ const MOUNT: Record<keyof Api, string> = {
events: "/api",
emailTemplates: "/api/admin",
evidence: "/api/admin",
guest: "/api/guest",
identity: "/api/identities",
inspectionPrefs: "/api/tenant/inspection-prefs",
inspectionRequests: "/api/inspection-requests",
Expand Down Expand Up @@ -273,7 +270,6 @@ export function createApi(context: AppLoadContext, opts: CreateApiOptions = {}):
events: mk<EventsApi>(MOUNT.events),
emailTemplates: mk<EmailTemplatesApi>(MOUNT.emailTemplates),
evidence: mk<EvidenceApi>(MOUNT.evidence),
guest: mk<GuestApi>(MOUNT.guest),
identity: mk<IdentityApi>(MOUNT.identity),
inspectionPrefs: mk<InspectionPrefsApi>(MOUNT.inspectionPrefs),
inspectionRequests: mk<InspectionRequestsApi>(MOUNT.inspectionRequests),
Expand Down
13 changes: 0 additions & 13 deletions app/lib/forms/auth.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,6 @@ export const joinSchema = z.object({

export type JoinInput = z.infer<typeof joinSchema>;

/**
* Guest-collaborator accept (`/guest-join`). Token comes from the URL. The
* guest creates a real (role-scoped, time-limited) account, so the form
* collects name + email + password — matching the API's POST /api/guest/claim.
*/
export const guestJoinSchema = z.object({
name: z.string().min(1, "Name is required").max(100, "Name is too long"),
email: z.string().email("Enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters").max(128, "Password is too long"),
});

export type GuestJoinInput = z.infer<typeof guestJoinSchema>;

/**
* Partner-agent invite accept (`/agent-invite/accept`). Token + email come from
* the invite (email is read-only), so only name + password are validated.
Expand Down
2 changes: 0 additions & 2 deletions app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ export default [
route("setup", "routes/setup.tsx"),
route("inspections/:id/form", "routes/form-renderer.tsx"),
route("join/:token", "routes/join.tsx"),
route("guest-join/:token", "routes/guest-join.tsx"),
route("conflict-resolver/:id", "routes/conflict-resolver.tsx"),
route("version-diff/:id", "routes/version-diff.tsx"),
// Standalone public — no layout (iframe-friendly)
Expand Down Expand Up @@ -108,7 +107,6 @@ export default [
route("templates", "routes/templates.tsx"),
route("team", "routes/team.tsx"),
route("metrics", "routes/metrics.tsx"),
route("apprentice-review", "routes/apprentice-review.tsx"),
route("reports", "routes/reports-redirect.tsx"),
layout("routes/settings-layout.tsx", [
route("settings", "routes/settings-hub.tsx"),
Expand Down
Loading
Loading