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
20 changes: 12 additions & 8 deletions echo/frontend/src/components/settings/MyAccessCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export const MyAccessCard = () => {

const totalOrganisations = data?.organisations.length ?? 0;
const totalWorkspaces = data?.workspaces.length ?? 0;
// Externals (no org membership) can't request a workspace.
const canCreateWorkspace = totalOrganisations > 0;

return (
<Card withBorder p="lg" radius="md">
Expand All @@ -122,14 +124,16 @@ export const MyAccessCard = () => {
/>
</Text>
</Stack>
<Button
variant="light"
size="sm"
leftSection={<IconPlus size={14} />}
onClick={() => navigate("/w/new")}
>
<Trans>New organisation workspace</Trans>
</Button>
{canCreateWorkspace && (
<Button
variant="light"
size="sm"
leftSection={<IconPlus size={14} />}
onClick={() => navigate("/w/new")}
>
<Trans>New organisation workspace</Trans>
</Button>
)}
</Group>

{byOrganisation.size === 0 ? (
Expand Down
17 changes: 14 additions & 3 deletions echo/frontend/src/routes/admin/AdminSettingsRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,9 @@ function BillingTable({
total_forecast_eur: number;
};
}) {
// useReactTable returns a stable ref; React Compiler caches our JSX on
// it, so state changes never reach the DOM. See frontend/AGENTS.md.
"use no memo";
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? []);
// TanStack's ExpandedState is Record<string, boolean> | true, but the
// 'true' shorthand isn't useful to us — we always track per-row
Expand Down Expand Up @@ -1451,19 +1454,25 @@ function UsageAndBillingPanel() {
{columnMenuItems.map((c) => (
<Menu.Item
key={c.id}
onClick={(e) => e.preventDefault()}
closeMenuOnClick={false}
onClick={() =>
setColumnVisibility((v) => ({
...v,
[c.id]: v[c.id] === false,
}))
}
>
<Checkbox
size="xs"
label={c.label}
checked={columnVisibility[c.id] !== false}
onChange={(e) =>
onChange={() =>
setColumnVisibility((v) => ({
...v,
[c.id]: e.currentTarget.checked,
[c.id]: v[c.id] === false,
}))
}
onClick={(e) => e.stopPropagation()}
/>
</Menu.Item>
))}
Expand Down Expand Up @@ -1551,6 +1560,8 @@ function SimpleDataTable<T extends object>({
initialSorting?: SortingState;
emptyLabel: string;
}) {
// See BillingTable / frontend/AGENTS.md for the rationale.
"use no memo";
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? []);
const table = useReactTable<T>({
columns,
Expand Down
28 changes: 25 additions & 3 deletions echo/frontend/src/routes/settings/UserSettingsRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import {
IconScale,
IconShieldLock,
} from "@tabler/icons-react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useCurrentUser } from "@/components/auth/hooks";
import { API_BASE_URL } from "@/config";
import { AccountSettingsCard } from "@/components/settings/AccountSettingsCard";
import { AuditLogsCard } from "@/components/settings/AuditLogsCard";
import { ChangePasswordCard } from "@/components/settings/ChangePasswordCard";
Expand Down Expand Up @@ -70,6 +72,26 @@ export const UserSettingsRoute = () => {

const isTwoFactorEnabled = Boolean(user?.tfa_enabled);

const { data: accessData } = useQuery<{
organisations: Array<{ id: string }>;
} | null>({
queryKey: ["v2", "workspaces"],
queryFn: async () => {
const res = await fetch(`${API_BASE_URL}/v2/workspaces`, {
credentials: "include",
});
if (!res.ok) return null;
return res.json();
},
staleTime: 60_000,
});
const isExternalOnly = (accessData?.organisations.length ?? 0) === 0;

const visibleSections = useMemo(
() => SECTIONS.filter((s) => !(isExternalOnly && s.id === "project-defaults")),
[isExternalOnly],
);

return (
<Container size="xl" py="xl">
<Stack gap="lg">
Expand Down Expand Up @@ -116,7 +138,7 @@ export const UserSettingsRoute = () => {

<Divider my="xs" />

{SECTIONS.map((section) => (
{visibleSections.map((section) => (
<NavLink
key={section.id}
label={section.label()}
Expand Down Expand Up @@ -178,7 +200,7 @@ export const UserSettingsRoute = () => {
</Stack>
)}

{activeSection === "project-defaults" && (
{activeSection === "project-defaults" && !isExternalOnly && (
<Stack gap="lg">
<Title order={3}>
<Trans>Project Defaults</Trans>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,14 @@ function AvatarBubbles({
members: MemberPreview[];
count: number;
}) {
const overflow = count - members.length;
const MAX_VISIBLE = 3;
const visible = members.slice(0, MAX_VISIBLE);
const overflow = count - visible.length;

return (
<Tooltip.Group>
<Avatar.Group spacing="sm">
{members.map((m, i) => (
{visible.map((m, i) => (
<Tooltip
key={`${m.display_name}-${i}`}
label={m.display_name}
Expand Down Expand Up @@ -168,8 +170,10 @@ function WorkspaceCard({
const isAdminOrOwner =
workspace.role === "admin" || workspace.role === "owner";
const ONE_DAY_MS = 86_400_000;
// Free workspaces are auto-created on signup, never "approved".
const isRecentlyApproved =
!!workspace.created_at &&
workspace.tier !== "free" &&
Date.now() - new Date(workspace.created_at).getTime() < ONE_DAY_MS;
const [hovered, setHovered] = useState(false);
const wsLogo = resolveLogoUrl(workspace.logo_url);
Expand Down
8 changes: 8 additions & 0 deletions echo/server/dembrane/api/v2/access_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,12 @@ async def request_workspace_access(
detail="Workspace not found", # intentional — don't confirm existence
)

if workspace.get("tier") == "free":
raise HTTPException(
status_code=404,
detail="Workspace not found",
)

org_role = await _org_role(org_id, app_user_id)
if org_role is None:
raise HTTPException(status_code=403, detail="Not a member of this organisation")
Expand Down Expand Up @@ -652,6 +658,8 @@ async def list_discoverable_workspaces(
}
if not is_org_admin:
filters["visibility"] = {"_neq": "private"}
# Free tier is the admin's personal allotment — not requestable.
filters["tier"] = {"_neq": "free"}

workspaces = await async_directus.get_items(
"workspace",
Expand Down
35 changes: 21 additions & 14 deletions echo/server/dembrane/api/v2/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from logging import getLogger
from datetime import datetime, timezone, timedelta

from fastapi import Query, APIRouter, HTTPException
from fastapi import Query, APIRouter, HTTPException, BackgroundTasks
from pydantic import Field, BaseModel

from dembrane.email import send_email
Expand Down Expand Up @@ -858,6 +858,7 @@ async def _notify_requester_approved(
granted_tier: str,
resulting_ws_id: str,
*,
workspace_name: Optional[str] = None,
approved_billing_period: Optional[str] = None,
proposed_billing_period: Optional[str] = None,
) -> None:
Expand All @@ -877,13 +878,7 @@ async def _notify_requester_approved(
base = (settings.urls.admin_base_url or "").rstrip("/")
workspace_url = f"{base}/w/{resulting_ws_id}" if base else f"/w/{resulting_ws_id}"

ws_name = ""
try:
ws = await async_directus.get_item("workspace", resulting_ws_id)
ws_name = (ws or {}).get("name", "")
except Exception: # noqa: BLE001
pass
ws_name = ws_name or req.get("proposed_name") or "your workspace"
ws_name = workspace_name or req.get("proposed_name") or "your workspace"

cadence_label = f"{approved_billing_period} billing" if approved_billing_period else None

Expand Down Expand Up @@ -1042,10 +1037,11 @@ async def _upgrade_workspace_for_request(
granted_tier_expires_at: Optional[str] = None,
granted_type_discount: Optional[str] = None,
granted_percent_discount: Optional[int] = None,
) -> str:
) -> tuple[str, str]:
"""Update an existing workspace's tier as part of approving a tier_upgrade request.

Reuses the tier-change logic from set_workspace_tier.
Reuses the tier-change logic from set_workspace_tier. Returns
(workspace_id, workspace_name) so callers can skip a re-fetch.
"""
from dembrane.policies import TIER_ORDER
from dembrane.tier_downgrade import apply_downgrade_effects
Expand Down Expand Up @@ -1115,7 +1111,7 @@ async def _upgrade_workspace_for_request(
req["id"],
staff_user_id,
)
return workspace_id
return workspace_id, workspace.get("name") or ""


@router.patch(
Expand All @@ -1126,6 +1122,7 @@ async def decide_workspace_request(
request_id: str,
body: DecideWorkspaceRequestBody,
auth: DependencyDirectusSession,
background_tasks: BackgroundTasks,
) -> DecideWorkspaceRequestResponse:
"""Staff approve or deny a workspace request.

Expand Down Expand Up @@ -1208,6 +1205,7 @@ async def decide_workspace_request(
)

resulting_ws_id: str
resulting_ws_name: str = ""
if req.get("kind") == "new_workspace":
resulting_ws_id = await _create_workspace_for_request(
req,
Expand All @@ -1217,13 +1215,15 @@ async def decide_workspace_request(
granted_type_discount=body.granted_type_discount,
granted_percent_discount=body.granted_percent_discount,
)
# We just created it with this name — no need to re-fetch.
resulting_ws_name = (req.get("proposed_name") or "").strip()
elif req.get("kind") == "tier_upgrade":
if not req.get("workspace_id"):
raise HTTPException(
status_code=400,
detail="tier_upgrade request missing workspace_id",
)
resulting_ws_id = await _upgrade_workspace_for_request(
resulting_ws_id, resulting_ws_name = await _upgrade_workspace_for_request(
req,
granted_tier=granted_tier,
staff_user_id=staff_user_id,
Expand All @@ -1250,10 +1250,13 @@ async def decide_workspace_request(

await async_directus.update_item("workspace_request", request_id, extra_data)

await _notify_requester_approved(
# Off the request path: SendGrid alone adds ~300–1000ms.
background_tasks.add_task(
_notify_requester_approved,
req,
granted_tier,
resulting_ws_id,
workspace_name=resulting_ws_name,
approved_billing_period=approved_billing_period,
proposed_billing_period=req.get("proposed_billing_period"),
)
Expand All @@ -1273,7 +1276,11 @@ async def decide_workspace_request(
staff_user_id,
)

await _notify_requester_denied(req, (body.denial_reason or "").strip())
background_tasks.add_task(
_notify_requester_denied,
req,
(body.denial_reason or "").strip(),
)

return DecideWorkspaceRequestResponse(
id=request_id,
Expand Down
Loading
Loading