Fix bugs
All checks were successful
Build and Push / build (push) Successful in 1m30s

This commit is contained in:
2026-04-29 12:16:00 +02:00
parent 542a607b53
commit c46f27edef
13 changed files with 1017 additions and 61 deletions

View File

@@ -10,23 +10,56 @@ interface OrgMember {
givenName: string;
familyName: string;
roles: string[];
authorizationId: string;
}
interface Props {
initialMembers: OrgMember[];
currentUserId: string;
/**
* Whether the viewing user can change other members' roles. True only
* for customer owners. Server enforces this independently — this prop
* is purely UX (don't render the control if the action would 403).
*/
canEditRoles: boolean;
}
type RoleOption = "owner" | "user";
/**
* TeamList — renders the org's members. Refreshes after invites by
* polling the API; the InviteForm broadcasts a `team:refresh` window
* event after a successful invite so the list updates immediately
* rather than waiting for the next reload.
*
* Slice 7 + Bug 25: owners can change other members' roles inline.
* Clicking the "Change role" button on a row swaps the badge for a
* dropdown + Save/Cancel pair. We deliberately don't use a modal —
* the change is a single-field edit and the user already sees the row
* context, so inline is faster.
*
* Self-row never shows the editor (server enforces too). Last-owner
* demotion is enforced server-side; we surface the resulting 409 as a
* row-local error rather than pre-validating client-side, because the
* client doesn't know the org's full owner count without an extra
* round trip.
*/
export function TeamList({ initialMembers, currentUserId }: Props) {
export function TeamList({
initialMembers,
currentUserId,
canEditRoles,
}: Props) {
const t = useTranslations("team");
const [members, setMembers] = useState<OrgMember[]>(initialMembers);
// Per-row editor state. `editingId` is the userId currently being
// edited (only one at a time). `pendingRole` is the dropdown value.
// `rowError` carries server-rejection messages keyed by userId.
const [editingId, setEditingId] = useState<string | null>(null);
const [pendingRole, setPendingRole] = useState<RoleOption>("user");
const [submitting, setSubmitting] = useState(false);
const [rowError, setRowError] = useState<Record<string, string>>({});
useEffect(() => {
function refresh() {
fetch("/api/team")
@@ -40,6 +73,50 @@ export function TeamList({ initialMembers, currentUserId }: Props) {
return () => window.removeEventListener("team:refresh", refresh);
}, []);
function startEdit(m: OrgMember) {
const current = (m.roles[0] === "owner" ? "owner" : "user") as RoleOption;
setEditingId(m.userId);
setPendingRole(current);
setRowError((e) => ({ ...e, [m.userId]: "" }));
}
function cancelEdit() {
setEditingId(null);
setSubmitting(false);
}
async function saveEdit(m: OrgMember) {
setSubmitting(true);
setRowError((e) => ({ ...e, [m.userId]: "" }));
try {
const res = await fetch(
`/api/team/${encodeURIComponent(m.userId)}/role`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: pendingRole }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("roleUpdateFailed"));
}
// Optimistic update — replace the row's roles locally rather than
// re-fetching the whole list. The list will eventually re-fetch
// on the next `team:refresh` event anyway.
setMembers((prev) =>
prev.map((x) =>
x.userId === m.userId ? { ...x, roles: [pendingRole] } : x
)
);
setEditingId(null);
} catch (err: any) {
setRowError((e) => ({ ...e, [m.userId]: err.message }));
} finally {
setSubmitting(false);
}
}
if (members.length === 0) {
return (
<div className="text-sm text-text-secondary text-center py-6 border border-border rounded-xl bg-surface-1">
@@ -51,47 +128,107 @@ export function TeamList({ initialMembers, currentUserId }: Props) {
return (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<ul className="divide-y divide-border">
{members.map((m) => (
<li
key={m.userId}
className="px-5 py-3 flex items-center justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">
{m.displayName || m.email}
</span>
{m.userId === currentUserId && (
<span className="text-[10px] uppercase tracking-wider text-accent">
{t("you")}
{members.map((m) => {
const isSelf = m.userId === currentUserId;
const isEditing = editingId === m.userId;
// Hide editor for self even when the viewer is an owner —
// self-demotion is server-blocked and offering it as a UI
// affordance would just produce errors.
const showEditor = canEditRoles && !isSelf;
const err = rowError[m.userId];
return (
<li
key={m.userId}
className="px-5 py-3 flex items-center justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">
{m.displayName || m.email}
</span>
{isSelf && (
<span className="text-[10px] uppercase tracking-wider text-accent">
{t("you")}
</span>
)}
</div>
<div className="text-xs text-text-muted truncate font-mono">
{m.email}
</div>
{err && (
<div className="text-xs text-red-400 mt-1">{err}</div>
)}
</div>
<div className="text-xs text-text-muted truncate font-mono">
{m.email}
<div className="flex items-center gap-2 shrink-0">
{isEditing ? (
<>
<select
value={pendingRole}
onChange={(e) =>
setPendingRole(e.target.value as RoleOption)
}
disabled={submitting}
className="text-xs bg-surface-2 border border-border rounded-md px-2 py-1 text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent"
>
<option value="user">{t("roleUser")}</option>
<option value="owner">{t("roleOwner")}</option>
</select>
<button
type="button"
onClick={() => saveEdit(m)}
disabled={submitting || !m.authorizationId}
className="text-xs px-2.5 py-1 rounded-md bg-accent text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
>
{t("save")}
</button>
<button
type="button"
onClick={cancelEdit}
disabled={submitting}
className="text-xs px-2.5 py-1 rounded-md border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{t("cancel")}
</button>
</>
) : (
<>
<div className="flex flex-wrap gap-1.5 justify-end">
{m.roles.length === 0 && (
<span className="text-[10px] uppercase tracking-wider text-text-muted bg-surface-3 px-2 py-0.5 rounded-full">
{t("noRole")}
</span>
)}
{m.roles.map((r) => (
<span
key={r}
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full ${
r === "owner"
? "bg-accent/15 text-accent border border-accent/20"
: "bg-surface-3 text-text-secondary border border-border"
}`}
>
{r}
</span>
))}
</div>
{showEditor && (
<button
type="button"
onClick={() => startEdit(m)}
title={t("changeRole")}
className="text-xs text-text-muted hover:text-text-primary px-2 py-1 rounded-md transition-colors"
>
{t("changeRole")}
</button>
)}
</>
)}
</div>
</div>
<div className="flex flex-wrap gap-1.5 justify-end">
{m.roles.length === 0 && (
<span className="text-[10px] uppercase tracking-wider text-text-muted bg-surface-3 px-2 py-0.5 rounded-full">
{t("noRole")}
</span>
)}
{m.roles.map((r) => (
<span
key={r}
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full ${
r === "owner"
? "bg-accent/15 text-accent border border-accent/20"
: "bg-surface-3 text-text-secondary border border-border"
}`}
>
{r}
</span>
))}
</div>
</li>
))}
</li>
);
})}
</ul>
</div>
);

View File

@@ -0,0 +1,43 @@
import Link from "next/link";
/**
* BackLink — small "← Page" navigation cue that sits above a page's
* `<h1 className="accent-rule">` heading.
*
* Why this exists
* ---------------
* The pattern was originally written inline on /team and /dashboard/new
* as `<Link className="inline-flex …"><span>←</span> Title</Link>`.
* That's wrong because `.accent-rule` (defined in globals.css) sets
* `display: inline-block` on the H1 — so an inline-flex link followed by
* an inline-block H1 are both inline-level, and end up on the same
* baseline whenever there's horizontal room for them. The `mb-4` on the
* link does nothing because vertical margin between inline boxes
* doesn't push siblings to a new line.
*
* Solving it: this component renders the link as a block-level flex
* container with `w-fit` so it shrinks to its content (and its hover
* area doesn't span the gutter). The trailing block element below sits
* cleanly on its own line.
*
* Use it whenever a page has a back-link above an `accent-rule` H1.
* The two prior callsites (/team and /dashboard/new) have been
* migrated; new pages should just use this directly.
*/
export function BackLink({
href,
label,
}: {
href: string;
label: string;
}) {
return (
<Link
href={href}
className="flex w-fit items-center gap-1.5 mb-4 text-xs font-medium text-text-muted hover:text-text-primary transition-colors"
>
<span aria-hidden="true"></span>
<span>{label}</span>
</Link>
);
}