feat: anthropic-style mesh + invite redesign (wave 1 checkpoint)
Ships the user-visible friction fixes and the foundation for the v2
invite protocol. API wiring + CLI client + email UI ship in wave 2.
Meshes — shipped
- Drop global UNIQUE on mesh.slug; mesh.id is canonical everywhere
- Server derives slug from name; create form has no slug field
- Two users can freely name their mesh "platform"; no collision errors
- Migration 0017
Invites v1 — shipped (URL shortener, backward compatible)
- New invite.code column (base62, 8 chars, nullable unique index)
- createMyInvite mints both token + short code; returns shortUrl
- GET /api/public/invite-code/:code resolves short code to token
- New route /i/[code] server-redirects to /join/[token]
- Invite generator UI shows short URL; QR encodes short URL
- Advanced fields (role/maxUses/expiresInDays) collapsed under disclosure
- Migration 0018
Invites v2 — foundation (broker + DB only; API+CLI+Web wiring in wave 2)
- Broker: canonicalInviteV2, verifyInviteV2, sealRootKeyToRecipient
- Broker: POST /invites/:code/claim endpoint (atomic single-use accounting)
- Broker tests: invite-v2.test.ts (signature, expiry, revocation, exhaustion)
- DB: mesh.invite gains version/capabilityV2/claimedByPubkey columns
- DB: new mesh.pending_invite table for email invites
- Migration 0019
- Contract locked in docs/protocol.md §v2 + SPEC.md §14b
Consent landing — shipped
- /join/[token] redesigned: explicit role, inviter, mesh stats, consent
- New server components: invite-card, role-badge, inviter-line, consent-summary
- "Join [mesh] as [Role]" primary action (not just "Join")
Error surfacing — shipped
- handle() now parses {error} responses from hono route catch blocks
- onError fallback includes timestamp so handle() can match apiErrorSchema
- Real error messages reach the UI instead of "Something went wrong"
Docs
- SPEC.md §14b: v2 invite protocol
- docs/protocol.md: v2 claim wire format
- docs/roadmap.md: status
- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
Deferred to wave 2/3
- API claim route wiring (packages/api)
- createMyInvite v2 capability generation
- Email invite mutation + Postmark delivery
- CLI v2 join flow (x25519 keypair + unseal)
- Web invite-generator email field + v2 display
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
apps/web/src/modules/join/consent-summary.tsx
Normal file
45
apps/web/src/modules/join/consent-summary.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
const BULLETS = [
|
||||
"Send and receive end-to-end encrypted messages with every peer on the mesh",
|
||||
"Read the shared audit log of mesh events",
|
||||
"Generate a local ed25519 keypair — your secret key never leaves your machine",
|
||||
] as const;
|
||||
|
||||
export function ConsentSummary() {
|
||||
return (
|
||||
<div
|
||||
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
|
||||
Joining this mesh will let you
|
||||
</div>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{BULLETS.map((text) => (
|
||||
<li
|
||||
key={text}
|
||||
className="flex items-start gap-2.5 text-[13.5px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="mt-[3px] shrink-0 text-[var(--cm-clay)]"
|
||||
>
|
||||
<path
|
||||
d="M5 12l4 4 10-10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
apps/web/src/modules/join/invite-card.tsx
Normal file
119
apps/web/src/modules/join/invite-card.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ConsentSummary } from "./consent-summary";
|
||||
import { InviterLine } from "./inviter-line";
|
||||
import { RoleBadge, roleLabel } from "./role-badge";
|
||||
|
||||
interface InviteCardProps {
|
||||
meshName: string;
|
||||
inviterName: string | null;
|
||||
role: "admin" | "member";
|
||||
memberCount: number;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export function InviteCard({
|
||||
meshName,
|
||||
inviterName,
|
||||
role,
|
||||
memberCount,
|
||||
expiresAt,
|
||||
}: InviteCardProps) {
|
||||
const peerWord = memberCount === 1 ? "peer" : "peers";
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="invite-heading"
|
||||
className="relative overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 p-7 md:p-9"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<div
|
||||
className="text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— invitation
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<h1
|
||||
id="invite-heading"
|
||||
className="mt-4 text-[clamp(1.9rem,3.6vw,2.65rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
You've been invited to join{" "}
|
||||
<span className="italic text-[var(--cm-clay)]">{meshName}</span>
|
||||
</h1>
|
||||
|
||||
{/* Inviter + stats row */}
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<InviterLine inviterName={inviterName} />
|
||||
<div
|
||||
className="flex items-center gap-2 text-[12.5px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-cactus)]"
|
||||
/>
|
||||
<span>
|
||||
{memberCount} {peerWord} · private mesh
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role badge */}
|
||||
<div className="mt-6">
|
||||
<RoleBadge role={role} />
|
||||
</div>
|
||||
|
||||
{/* Consent bullets */}
|
||||
<div className="mt-5">
|
||||
<ConsentSummary />
|
||||
</div>
|
||||
|
||||
{/* Primary action block */}
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<a
|
||||
href="#install"
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-[var(--cm-radius-md)] bg-[var(--cm-clay)] px-6 py-4 text-[15px] font-medium text-[var(--cm-gray-050)] transition-colors hover:bg-[var(--cm-clay-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cm-clay)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--cm-bg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
aria-label={`Join ${meshName} as ${roleLabel(role)}`}
|
||||
>
|
||||
Join {meshName} as {roleLabel(role)}
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14M13 5l7 7-7 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<p
|
||||
className="flex flex-wrap items-center justify-between gap-2 text-[11.5px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span>
|
||||
valid until{" "}
|
||||
{expiresAt.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
Not you? Sign out
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
29
apps/web/src/modules/join/inviter-line.tsx
Normal file
29
apps/web/src/modules/join/inviter-line.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
interface InviterLineProps {
|
||||
inviterName: string | null;
|
||||
}
|
||||
|
||||
export function InviterLine({ inviterName }: InviterLineProps) {
|
||||
const initial = (inviterName ?? "?").trim().charAt(0).toUpperCase() || "?";
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] text-[13px] font-medium text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-[11px] uppercase tracking-[0.16em] text-[var(--cm-fg-tertiary)]">
|
||||
Invited by
|
||||
</span>
|
||||
<span className="text-[14.5px] font-medium text-[var(--cm-fg)]">
|
||||
{inviterName ?? "the mesh owner"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
apps/web/src/modules/join/role-badge.tsx
Normal file
110
apps/web/src/modules/join/role-badge.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
type Role = "admin" | "member";
|
||||
|
||||
const ROLE_CONFIG: Record<
|
||||
Role,
|
||||
{
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
accent: string;
|
||||
dot: string;
|
||||
}
|
||||
> = {
|
||||
admin: {
|
||||
label: "Admin",
|
||||
description:
|
||||
"Full control: invite and remove peers, manage settings, send and receive messages.",
|
||||
// subtle warning treatment — fig (pinkish) accent, not alarming
|
||||
accent: "#c46686",
|
||||
dot: "#c46686",
|
||||
icon: (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2l3 6 6 1-4.5 4.5L18 20l-6-3-6 3 1.5-6.5L3 9l6-1 3-6z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
member: {
|
||||
label: "Member",
|
||||
description:
|
||||
"Send and receive messages, read the shared audit log, participate in mesh traffic.",
|
||||
accent: "var(--cm-clay)",
|
||||
dot: "var(--cm-clay)",
|
||||
icon: (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M4 20c0-4 4-6 8-6s8 2 8 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
interface RoleBadgeProps {
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export function RoleBadge({ role }: RoleBadgeProps) {
|
||||
const cfg = ROLE_CONFIG[role];
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 rounded-[var(--cm-radius-md)] border p-4"
|
||||
style={{
|
||||
borderColor: cfg.accent,
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--cm-bg-elevated) 70%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
color: cfg.accent,
|
||||
backgroundColor: "color-mix(in srgb, var(--cm-bg) 60%, transparent)",
|
||||
border: `1px solid ${cfg.accent}`,
|
||||
}}
|
||||
>
|
||||
{cfg.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="flex items-center gap-2 text-[13px] font-medium"
|
||||
style={{ color: cfg.accent, fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<span className="uppercase tracking-[0.14em]">
|
||||
You'll join as {cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="mt-1 text-[13.5px] leading-[1.55] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{cfg.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function roleLabel(role: Role) {
|
||||
return ROLE_CONFIG[role].label;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
@@ -32,14 +31,6 @@ import {
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
|
||||
export const CreateMeshForm = ({
|
||||
onboarding = false,
|
||||
}: { onboarding?: boolean } = {}) => {
|
||||
@@ -48,30 +39,16 @@ export const CreateMeshForm = ({
|
||||
resolver: zodResolver(createMyMeshInputSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
visibility: "private",
|
||||
transport: "managed",
|
||||
},
|
||||
});
|
||||
|
||||
const nameValue = form.watch("name");
|
||||
const slugDirty = form.formState.dirtyFields.slug;
|
||||
|
||||
useEffect(() => {
|
||||
if (!slugDirty && nameValue) {
|
||||
form.setValue("slug", slugify(nameValue));
|
||||
}
|
||||
}, [nameValue, slugDirty, form]);
|
||||
|
||||
const onSubmit = async (values: CreateMyMeshInput) => {
|
||||
try {
|
||||
const res = (await handle(api.my.meshes.$post)({
|
||||
json: values,
|
||||
})) as { id: string; slug: string } | { error: string };
|
||||
if ("error" in res) {
|
||||
form.setError("slug", { message: res.error });
|
||||
return;
|
||||
}
|
||||
})) as { id: string; slug: string };
|
||||
router.push(
|
||||
onboarding
|
||||
? `${pathsConfig.dashboard.user.meshes.invite(res.id)}?onboarding=1`
|
||||
@@ -97,23 +74,7 @@ export const CreateMeshForm = ({
|
||||
<Input placeholder="Platform team" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Display name — what teammates see.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="platform-team" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
URL-safe identifier: lowercase letters, digits, hyphens.
|
||||
Display name — what teammates see. Pick anything.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -36,6 +36,8 @@ interface GeneratedInvite {
|
||||
token: string;
|
||||
inviteLink: string;
|
||||
joinUrl: string;
|
||||
/** Short human-friendly URL, preferred for sharing. Null if the backend didn't mint one. */
|
||||
shortUrl: string | null;
|
||||
expiresAt: Date;
|
||||
qrDataUrl: string;
|
||||
}
|
||||
@@ -43,6 +45,7 @@ interface GeneratedInvite {
|
||||
export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
const [result, setResult] = useState<GeneratedInvite | null>(null);
|
||||
const [copied, setCopied] = useState<"url" | "cli" | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const form = useForm<CreateMyInviteInput>({
|
||||
resolver: zodResolver(createMyInviteInputSchema),
|
||||
@@ -54,24 +57,20 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
const res = (await handle(api.my.meshes[":id"].invites.$post)({
|
||||
param: { id: meshId },
|
||||
json: values,
|
||||
})) as
|
||||
| {
|
||||
id: string;
|
||||
token: string;
|
||||
inviteLink: string;
|
||||
joinUrl: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
| { error: string };
|
||||
})) as {
|
||||
id: string;
|
||||
token: string;
|
||||
inviteLink: string;
|
||||
joinUrl: string;
|
||||
shortUrl: string | null;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
if ("error" in res) {
|
||||
form.setError("root", { message: res.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// QR encodes the HTTPS join URL now — anyone with a camera can
|
||||
// scan and land on the friendly /join/[token] page.
|
||||
const qrDataUrl = await QRCode.toDataURL(res.joinUrl, {
|
||||
// QR encodes the SHORT URL when available — scannable at camera distance
|
||||
// and short enough for the QR to stay low-density. Falls back to the
|
||||
// long token URL for legacy invites minted before the shortener shipped.
|
||||
const qrTarget = res.shortUrl ?? res.joinUrl;
|
||||
const qrDataUrl = await QRCode.toDataURL(qrTarget, {
|
||||
width: 256,
|
||||
margin: 1,
|
||||
color: { dark: "#141413", light: "#ffffff" },
|
||||
@@ -82,6 +81,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
token: res.token,
|
||||
inviteLink: res.inviteLink,
|
||||
joinUrl: res.joinUrl,
|
||||
shortUrl: res.shortUrl,
|
||||
expiresAt: new Date(res.expiresAt),
|
||||
qrDataUrl,
|
||||
});
|
||||
@@ -99,6 +99,10 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
};
|
||||
|
||||
if (result) {
|
||||
// Prefer the short URL everywhere it exists. CLI command still uses the
|
||||
// long token because the broker resolves by token — swapping CLI to short
|
||||
// codes is part of the v2 protocol, not this URL-shortener change.
|
||||
const primaryUrl = result.shortUrl ?? result.joinUrl;
|
||||
const cliCmd = `claudemesh join ${result.token}`;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -117,7 +121,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
Share this link
|
||||
</div>
|
||||
<code className="bg-muted block break-all rounded p-3 font-mono text-xs">
|
||||
{result.joinUrl}
|
||||
{primaryUrl}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
@@ -126,7 +130,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => copy(result.joinUrl, "url")} size="sm">
|
||||
<Button onClick={() => copy(primaryUrl, "url")} size="sm">
|
||||
{copied === "url" ? "Copied ✓" : "Copy link"}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -168,65 +172,89 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-md space-y-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
One-time invite for a new member. Valid for 7 days.
|
||||
</p>
|
||||
|
||||
{/* Advanced options — hidden by default. Defaults ship 90% of users. */}
|
||||
<div className="rounded-md border border-dashed">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((s) => !s)}
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between px-3 py-2 text-xs uppercase tracking-wider"
|
||||
aria-expanded={showAdvanced}
|
||||
>
|
||||
<span>Advanced</span>
|
||||
<span aria-hidden="true">{showAdvanced ? "−" : "+"}</span>
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 border-t px-3 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxUses"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max uses</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresInDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Expires in (days)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxUses"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max uses</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresInDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Expires in (days)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.formState.errors.root && (
|
||||
<p className="text-destructive text-sm">
|
||||
{form.formState.errors.root.message}
|
||||
|
||||
Reference in New Issue
Block a user