151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|