Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 0 additions & 55 deletions echo/directus/sync/snapshot/fields/project_membership/role.json

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"collection": "workspace_invite",
"field": "project_id",
"type": "uuid",
"meta": {
"collection": "workspace_invite",
"conditions": null,
"display": null,
"display_options": null,
"field": "project_id",
"group": null,
"hidden": false,
"interface": "select-dropdown-m2o",
"note": "Private project to share with the invitee on join (sharing modal)",
"options": {
"template": "{{name}}"
},
"readonly": false,
"required": false,
"searchable": true,
"sort": 10,
"special": [
"m2o"
],
"translations": null,
"validation": null,
"validation_message": null,
"width": "half"
},
"schema": {
"name": "project_id",
"table": "workspace_invite",
"data_type": "uuid",
"default_value": null,
"max_length": null,
"numeric_precision": null,
"numeric_scale": null,
"is_nullable": true,
"is_unique": false,
"is_indexed": false,
"is_primary_key": false,
"is_generated": false,
"generation_expression": null,
"has_auto_increment": false,
"foreign_key_table": "project",
"foreign_key_column": "id"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"collection": "workspace_invite",
"field": "project_id",
"related_collection": "project",
"meta": {
"junction_field": null,
"many_collection": "workspace_invite",
"many_field": "project_id",
"one_allowed_collections": null,
"one_collection": "project",
"one_collection_field": null,
"one_deselect_action": "nullify",
"one_field": null,
"sort_field": null
},
"schema": {
"table": "workspace_invite",
"column": "project_id",
"foreign_key_table": "project",
"foreign_key_column": "id",
"constraint_name": "workspace_invite_project_id_foreign",
"on_update": "NO ACTION",
"on_delete": "SET NULL"
}
}
7 changes: 7 additions & 0 deletions echo/frontend/src/components/invite/EmailChipsInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface Props {
// When provided (org admins), the input offers these people as a dropdown.
// Absent → plain free-text behaviour, unchanged for everyone else.
suggestions?: MemberSuggestion[];
disabled?: boolean;
"data-testid"?: string;
}

Expand Down Expand Up @@ -86,6 +87,7 @@ export function EmailChipsInput({
selfEmail,
autoFocus,
suggestions,
disabled,
"data-testid": dataTestId,
}: Props) {
const [draft, setDraft] = useState("");
Expand Down Expand Up @@ -251,6 +253,7 @@ export function EmailChipsInput({
key={chip.id}
chip={chip}
highlighted={chip.id === pendingDeleteId}
disabled={disabled}
onRemove={() => removeChip(chip.id)}
/>
))}
Expand All @@ -264,6 +267,7 @@ export function EmailChipsInput({
: ""
}
value={draft}
disabled={disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
Expand Down Expand Up @@ -335,10 +339,12 @@ export function EmailChipsInput({
function EmailChipPill({
chip,
highlighted,
disabled,
onRemove,
}: {
chip: EmailChip;
highlighted?: boolean;
disabled?: boolean;
onRemove: () => void;
}) {
const baseTone =
Expand Down Expand Up @@ -387,6 +393,7 @@ function EmailChipPill({
size="xs"
variant="subtle"
color={chip.state === "valid" ? "gray" : "red"}
disabled={disabled}
onClick={onRemove}
aria-label={t`Remove ${chip.value}`}
>
Expand Down
61 changes: 6 additions & 55 deletions echo/frontend/src/components/invite/InviteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import {
type InviteResultState,
InviteResultsList,
} from "@/components/invite/InviteResultsList";
import {
inviteToOrg,
inviteToWorkspace,
type WorkspaceInvitePayload,
} from "@/components/invite/api";
import { type InviteRole, RoleSelect } from "@/components/invite/RoleSelect";
import {
type InviteableWorkspace,
Expand Down Expand Up @@ -84,61 +89,6 @@ async function fetchOrgMembers(orgId: string): Promise<OrgMemberLite[]> {
return Array.isArray(data) ? (data as OrgMemberLite[]) : [];
}

type WorkspaceInvitePayload = {
status: string;
email: string;
email_sent: boolean;
invite_url?: string | null;
};

async function inviteToWorkspace(
workspaceId: string,
email: string,
role: InviteRole,
): Promise<WorkspaceInvitePayload> {
const res = await fetch(
`${API_BASE_URL}/v2/workspaces/${workspaceId}/invite`,
{
body: JSON.stringify({ email, role }),
credentials: "include",
headers: { "Content-Type": "application/json" },
method: "POST",
},
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(
(data && (data.detail as string)) ||
`Workspace invite failed (${res.status})`,
);
(err as Error & { status?: number }).status = res.status;
throw err;
}
return data as WorkspaceInvitePayload;
}

async function inviteToOrg(
orgId: string,
email: string,
role: InviteRole,
): Promise<{ status: string; email: string; invite_url?: string | null }> {
const res = await fetch(`${API_BASE_URL}/v2/orgs/${orgId}/invites`, {
body: JSON.stringify({ email, role }),
credentials: "include",
headers: { "Content-Type": "application/json" },
method: "POST",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(
(data && (data.detail as string)) || `Org invite failed (${res.status})`,
);
(err as Error & { status?: number }).status = res.status;
throw err;
}
return data as { status: string; email: string; invite_url?: string | null };
}

// One row of the cost preview, scoped to a single billing context (the org's
// pooled account, or a partner workspace's own account).
type ConfirmLine = {
Expand Down Expand Up @@ -470,6 +420,7 @@ export function InviteModal({
count: emailCount,
role: submittedRole,
workspace_count: workspaceIds.length,
source: "invite_modal",
});
// Centralised helpers fan out to both query namespaces during the migration window.
invalidateOrgMembersEverywhere(queryClient, orgId);
Expand Down
16 changes: 13 additions & 3 deletions echo/frontend/src/components/invite/RoleSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { Alert, Radio, Stack, Text } from "@mantine/core";
import { useMemo } from "react";
import { roleLevel } from "@/lib/roles";

export type InviteRole = "member" | "billing" | "admin" | "external" | "observer";
export type InviteRole =
| "member"
| "billing"
| "admin"
| "external"
| "observer";

interface Props {
value: InviteRole;
Expand All @@ -13,6 +18,8 @@ interface Props {
inviterLevel: "member" | "admin" | "owner";
/** Whether the modal is in workspace-only mode (no zero-workspace submit). */
allowExternal?: boolean;
// Roles to leave out entirely (e.g. billing can't open projects, observer only exists in external-client workspaces).
exclude?: InviteRole[];
disabled?: boolean;
"data-testid"?: string;
}
Expand All @@ -23,6 +30,7 @@ export function RoleSelect({
onChange,
inviterLevel,
allowExternal = true,
exclude = [],
disabled,
"data-testid": dataTestId,
}: Props) {
Expand Down Expand Up @@ -59,8 +67,10 @@ export function RoleSelect({
});
}
// Funnel every option through the same hierarchy gate; levels come from lib/roles.ts (mirrors backend policies.py).
return rows.filter((r) => roleLevel(r.value) <= inviterRank);
}, [inviterRank, allowExternal]);
return rows
.filter((r) => !exclude.includes(r.value))
.filter((r) => roleLevel(r.value) <= inviterRank);
}, [inviterRank, allowExternal, exclude]);

return (
<Stack gap={6}>
Expand Down
Loading
Loading