This commit is contained in:
@@ -40,6 +40,17 @@ function NavBar() {
|
||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
||||
{t("dashboard")}
|
||||
</NavLink>
|
||||
{/* Slice 7: /team is owner+platform only. Match server-side
|
||||
gate (canMutate). The roles array carries either "owner"
|
||||
or "user" for customer sessions; isPlatform covers the
|
||||
platform side. */}
|
||||
{user &&
|
||||
(user.isPlatform ||
|
||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
||||
<NavLink href="/team" active={pathname === "/team"}>
|
||||
{t("team")}
|
||||
</NavLink>
|
||||
)}
|
||||
{user?.isPlatform && (
|
||||
<NavLink href="/admin" active={pathname === "/admin"}>
|
||||
{t("admin")}
|
||||
|
||||
150
src/components/team/invite-form.tsx
Normal file
150
src/components/team/invite-form.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type FormState = "idle" | "submitting" | "success" | "error";
|
||||
|
||||
/**
|
||||
* InviteForm — owner submits email + name + role to /api/team/invite.
|
||||
* On success, broadcasts `team:refresh` so the sibling TeamList
|
||||
* re-fetches the member list.
|
||||
*
|
||||
* Form fields mirror the POST body:
|
||||
* { email, givenName, familyName, role: "owner" | "user" }
|
||||
*
|
||||
* Role defaults to "user" — the more conservative grant. Owner
|
||||
* promotion happens in ZITADEL Console for now.
|
||||
*/
|
||||
export function InviteForm() {
|
||||
const t = useTranslations("team");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [form, setForm] = useState({
|
||||
email: "",
|
||||
givenName: "",
|
||||
familyName: "",
|
||||
role: "user" as "owner" | "user",
|
||||
});
|
||||
const [state, setState] = useState<FormState>("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
|
||||
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setState("submitting");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/team/invite", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.code === "user_already_exists") {
|
||||
throw new Error(t("inviteUserExists"));
|
||||
}
|
||||
throw new Error(data.error || "Invite failed");
|
||||
}
|
||||
setState("success");
|
||||
setForm({ email: "", givenName: "", familyName: "", role: "user" });
|
||||
// Tell the TeamList sibling to refresh
|
||||
window.dispatchEvent(new Event("team:refresh"));
|
||||
|
||||
// Auto-clear the success banner after a moment so the form
|
||||
// doesn't permanently look "done"
|
||||
setTimeout(() => setState("idle"), 3500);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("givenName")}
|
||||
</label>
|
||||
<input
|
||||
name="givenName"
|
||||
type="text"
|
||||
required
|
||||
value={form.givenName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("familyName")}
|
||||
</label>
|
||||
<input
|
||||
name="familyName"
|
||||
type="text"
|
||||
required
|
||||
value={form.familyName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="colleague@company.ch"
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{t("role")}
|
||||
</label>
|
||||
<select
|
||||
name="role"
|
||||
value={form.role}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
>
|
||||
<option value="user">{t("roleUser")}</option>
|
||||
<option value="owner">{t("roleOwner")}</option>
|
||||
</select>
|
||||
<p className="text-xs text-text-muted mt-1">{t("roleHint")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{state === "success" && (
|
||||
<div className="text-xs text-emerald-400 bg-emerald-400/10 border border-emerald-400/20 rounded-lg px-3 py-2">
|
||||
{t("inviteSent")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === "submitting"}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state === "submitting" ? tCommon("loading") : t("inviteButton")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
98
src/components/team/team-list.tsx
Normal file
98
src/components/team/team-list.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface OrgMember {
|
||||
userId: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialMembers: OrgMember[];
|
||||
currentUserId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function TeamList({ initialMembers, currentUserId }: Props) {
|
||||
const t = useTranslations("team");
|
||||
const [members, setMembers] = useState<OrgMember[]>(initialMembers);
|
||||
|
||||
useEffect(() => {
|
||||
function refresh() {
|
||||
fetch("/api/team")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.members) setMembers(data.members);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
window.addEventListener("team:refresh", refresh);
|
||||
return () => window.removeEventListener("team:refresh", refresh);
|
||||
}, []);
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-text-secondary text-center py-6 border border-border rounded-xl bg-surface-1">
|
||||
{t("noMembers")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted truncate font-mono">
|
||||
{m.email}
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
src/components/tenants/assigned-users-panel.tsx
Normal file
231
src/components/tenants/assigned-users-panel.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface Assignment {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
assignedAt: string;
|
||||
assignedBy: string;
|
||||
orphan: boolean;
|
||||
}
|
||||
|
||||
interface OrgMember {
|
||||
userId: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tenantName: string;
|
||||
/**
|
||||
* When false, the panel renders read-only — assignments are visible
|
||||
* but the add-user form and remove ✕ buttons are hidden. Pass
|
||||
* `canEdit` from the parent server component (= canMutate(user)).
|
||||
*/
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AssignedUsersPanel — manages the tenant_user_assignments rows for
|
||||
* one tenant. Owner sees:
|
||||
* - List of currently-assigned users with name, email, role, and
|
||||
* an "X" button to revoke.
|
||||
* - Dropdown of org members not yet assigned + "Assign" button.
|
||||
*
|
||||
* `user`-role members see the panel read-only (canEdit=false): they
|
||||
* see who else has access to the tenant they're working with, but
|
||||
* can't change anything.
|
||||
*/
|
||||
export function AssignedUsersPanel({ tenantName, canEdit }: Props) {
|
||||
const t = useTranslations("assignments");
|
||||
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
||||
const [members, setMembers] = useState<OrgMember[] | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pickedUserId, setPickedUserId] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError("");
|
||||
try {
|
||||
const [aRes, mRes] = await Promise.all([
|
||||
fetch(`/api/tenants/${tenantName}/assignments`),
|
||||
canEdit
|
||||
? fetch(`/api/team`)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
if (!aRes.ok) throw new Error("Failed to load assignments");
|
||||
const aData = await aRes.json();
|
||||
setAssignments(aData.assignments ?? []);
|
||||
|
||||
if (mRes && mRes.ok) {
|
||||
const mData = await mRes.json();
|
||||
setMembers(mData.members ?? []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}, [tenantName, canEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function handleAssign() {
|
||||
if (!pickedUserId || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`/api/tenants/${tenantName}/assignments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: pickedUserId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Assign failed");
|
||||
}
|
||||
setPickedUserId("");
|
||||
await refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(userId: string) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tenants/${tenantName}/assignments/${encodeURIComponent(userId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Revoke failed");
|
||||
}
|
||||
await refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (assignments === null) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("loading")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Compute candidates for the assign dropdown: members of the org
|
||||
// who hold the `user` role (not owners — they have implicit access)
|
||||
// and aren't already assigned.
|
||||
const assignedIds = new Set(assignments.map((a) => a.userId));
|
||||
const candidates = (members ?? []).filter(
|
||||
(m) =>
|
||||
!assignedIds.has(m.userId) &&
|
||||
m.roles.includes("user") &&
|
||||
!m.roles.includes("owner")
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
|
||||
{error}
|
||||
<button
|
||||
onClick={() => setError("")}
|
||||
className="ml-2 text-red-300 hover:text-red-200"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assignments.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary text-center py-3">
|
||||
{t("noneAssigned")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border -mx-2">
|
||||
{assignments.map((a) => (
|
||||
<li
|
||||
key={a.userId}
|
||||
className="px-2 py-2 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary truncate">
|
||||
{a.orphan ? (
|
||||
<span className="text-text-muted italic">
|
||||
{a.displayName}
|
||||
</span>
|
||||
) : (
|
||||
a.displayName
|
||||
)}
|
||||
</div>
|
||||
{a.email && (
|
||||
<div className="text-xs text-text-muted truncate font-mono">
|
||||
{a.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => handleRevoke(a.userId)}
|
||||
disabled={busy}
|
||||
className="text-text-muted/60 hover:text-red-400 transition-colors disabled:opacity-50 text-sm px-2"
|
||||
title={t("revoke")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
{candidates.length === 0 ? (
|
||||
<p className="text-xs text-text-muted text-center py-2">
|
||||
{t("noCandidates")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={pickedUserId}
|
||||
onChange={(e) => setPickedUserId(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
>
|
||||
<option value="">{t("pickUser")}</option>
|
||||
{candidates.map((m) => (
|
||||
<option key={m.userId} value={m.userId}>
|
||||
{m.displayName || m.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={busy || !pickedUserId}
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? "…" : t("assign")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user