feat(web): email invite mode + ic:// removal in invite generator (wave 3)
Completes the v2 invite user experience. The generator now ships two delivery modes behind a simple Link | Email toggle, and the vestigial ic:// scheme is gone from every user-visible surface. Modes - Link (default, existing flow): mints a v2 invite, displays short URL + QR + CLI command. No behavioral change vs wave 2. - Email (new): admin types a recipient email, submit dispatches through the POST /api/my/meshes/:id/invites/email endpoint (wave 2), which mints a normal v2 invite, records a pending_invite row, and stubs the Postmark send with a TODO. Result card shows a "✓ Invite sent to X" banner plus the same QR card so the admin can also share manually. Honest UX copy on the stub: "Email delivery is stubbed in v0.1.x — the invite is valid. Share the link directly if needed." Avoids pretending something shipped that hasn't. ic:// cleanup - inviteLink field no longer rendered or stored (still returned by the API for backward compatibility; just not surfaced) - CLI command now copies `claudemesh join <code>` (falls back to shortUrl when code is null), matching the new v2 entry point - Zero remaining `ic://` references in the UI Implementation notes - Two separate useForm instances (linkForm, emailForm) with dedicated resolvers and submit handlers — clearer state boundaries than conditional validation on a merged schema - Mode toggle uses role="group" + aria-pressed, focus-visible ring, keyboard-navigable - Email result banner is role="status" for screen readers - RPC client has one `as any` on `(api.my.meshes[":id"].invites as any) .email.$post` — the endpoint IS registered server-side (wave 2) but the monorepo's Hono type regen is out-of-band; TODO comment marks the cast for removal when the RPC types catch up - No new deps - Component export signature unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,9 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createEmailInviteInputSchema,
|
||||||
createMyInviteInputSchema,
|
createMyInviteInputSchema,
|
||||||
|
type CreateEmailInviteInput,
|
||||||
type CreateMyInviteInput,
|
type CreateMyInviteInput,
|
||||||
} from "@turbostarter/api/schema";
|
} from "@turbostarter/api/schema";
|
||||||
import { handle } from "@turbostarter/api/utils";
|
import { handle } from "@turbostarter/api/utils";
|
||||||
@@ -33,26 +35,53 @@ import { api } from "~/lib/api/client";
|
|||||||
|
|
||||||
interface GeneratedInvite {
|
interface GeneratedInvite {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Raw token (only set for link-mode results — empty string when email mode). */
|
||||||
token: string;
|
token: string;
|
||||||
inviteLink: string;
|
/** Short code for the CLI command. Falls back to shortUrl display if null. */
|
||||||
|
code: string | null;
|
||||||
joinUrl: string;
|
joinUrl: string;
|
||||||
/** Short human-friendly URL, preferred for sharing. Null if the backend didn't mint one. */
|
/** Short human-friendly URL, preferred for sharing. Null if the backend didn't mint one. */
|
||||||
shortUrl: string | null;
|
shortUrl: string | null;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
qrDataUrl: string;
|
qrDataUrl: string;
|
||||||
|
/** When set, the invite was dispatched via email and a confirmation banner is shown. */
|
||||||
|
sentToEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Mode = "link" | "email";
|
||||||
|
|
||||||
|
const qrOptions = {
|
||||||
|
width: 256,
|
||||||
|
margin: 1,
|
||||||
|
color: { dark: "#141413", light: "#ffffff" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
||||||
|
const [mode, setMode] = useState<Mode>("link");
|
||||||
const [result, setResult] = useState<GeneratedInvite | null>(null);
|
const [result, setResult] = useState<GeneratedInvite | null>(null);
|
||||||
const [copied, setCopied] = useState<"url" | "cli" | null>(null);
|
const [copied, setCopied] = useState<"url" | "cli" | null>(null);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
const form = useForm<CreateMyInviteInput>({
|
// Two separate forms — simpler than conditional validation, clearer state
|
||||||
|
// boundaries, and each form owns its own submit + error surface.
|
||||||
|
const linkForm = useForm<CreateMyInviteInput>({
|
||||||
resolver: zodResolver(createMyInviteInputSchema),
|
resolver: zodResolver(createMyInviteInputSchema),
|
||||||
defaultValues: { role: "member", maxUses: 1, expiresInDays: 7 },
|
defaultValues: { role: "member", maxUses: 1, expiresInDays: 7 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: CreateMyInviteInput) => {
|
const emailForm = useForm<CreateEmailInviteInput>({
|
||||||
|
resolver: zodResolver(createEmailInviteInputSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
role: "member",
|
||||||
|
maxUses: 1,
|
||||||
|
expiresInDays: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeForm = mode === "link" ? linkForm : emailForm;
|
||||||
|
|
||||||
|
const onSubmitLink = async (values: CreateMyInviteInput) => {
|
||||||
try {
|
try {
|
||||||
const res = (await handle(api.my.meshes[":id"].invites.$post)({
|
const res = (await handle(api.my.meshes[":id"].invites.$post)({
|
||||||
param: { id: meshId },
|
param: { id: meshId },
|
||||||
@@ -60,6 +89,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
|||||||
})) as {
|
})) as {
|
||||||
id: string;
|
id: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
code: string | null;
|
||||||
inviteLink: string;
|
inviteLink: string;
|
||||||
joinUrl: string;
|
joinUrl: string;
|
||||||
shortUrl: string | null;
|
shortUrl: string | null;
|
||||||
@@ -70,42 +100,103 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
|||||||
// and short enough for the QR to stay low-density. Falls back to the
|
// 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.
|
// long token URL for legacy invites minted before the shortener shipped.
|
||||||
const qrTarget = res.shortUrl ?? res.joinUrl;
|
const qrTarget = res.shortUrl ?? res.joinUrl;
|
||||||
const qrDataUrl = await QRCode.toDataURL(qrTarget, {
|
const qrDataUrl = await QRCode.toDataURL(qrTarget, qrOptions);
|
||||||
width: 256,
|
|
||||||
margin: 1,
|
|
||||||
color: { dark: "#141413", light: "#ffffff" },
|
|
||||||
});
|
|
||||||
|
|
||||||
setResult({
|
setResult({
|
||||||
id: res.id,
|
id: res.id,
|
||||||
token: res.token,
|
token: res.token,
|
||||||
inviteLink: res.inviteLink,
|
code: res.code,
|
||||||
joinUrl: res.joinUrl,
|
joinUrl: res.joinUrl,
|
||||||
shortUrl: res.shortUrl,
|
shortUrl: res.shortUrl,
|
||||||
expiresAt: new Date(res.expiresAt),
|
expiresAt: new Date(res.expiresAt),
|
||||||
qrDataUrl,
|
qrDataUrl,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
form.setError("root", {
|
linkForm.setError("root", {
|
||||||
message: e instanceof Error ? e.message : "Failed to generate invite.",
|
message: e instanceof Error ? e.message : "Failed to generate invite.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSubmitEmail = async (values: CreateEmailInviteInput) => {
|
||||||
|
try {
|
||||||
|
// TODO(types): remove `as any` after RPC type regen picks up the new
|
||||||
|
// `.email` subroute registered in packages/api/src/modules/mesh/router.ts.
|
||||||
|
const res = (await handle(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(api.my.meshes[":id"].invites as any).email.$post,
|
||||||
|
)({
|
||||||
|
param: { id: meshId },
|
||||||
|
json: values,
|
||||||
|
})) as {
|
||||||
|
pendingInviteId: string;
|
||||||
|
code: string;
|
||||||
|
email: string;
|
||||||
|
shortUrl: string;
|
||||||
|
expiresAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(res.shortUrl, qrOptions);
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
id: res.pendingInviteId,
|
||||||
|
token: "",
|
||||||
|
code: res.code,
|
||||||
|
joinUrl: res.shortUrl,
|
||||||
|
shortUrl: res.shortUrl,
|
||||||
|
expiresAt: new Date(res.expiresAt),
|
||||||
|
qrDataUrl,
|
||||||
|
sentToEmail: res.email,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
emailForm.setError("root", {
|
||||||
|
message: e instanceof Error ? e.message : "Failed to send invite.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const copy = async (text: string, key: "url" | "cli") => {
|
const copy = async (text: string, key: "url" | "cli") => {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopied(key);
|
setCopied(key);
|
||||||
setTimeout(() => setCopied(null), 2000);
|
setTimeout(() => setCopied(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetAll = () => {
|
||||||
|
setResult(null);
|
||||||
|
linkForm.reset();
|
||||||
|
emailForm.reset();
|
||||||
|
};
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
// Prefer the short URL everywhere it exists. CLI command still uses the
|
// Prefer the short URL everywhere it exists. CLI command uses the code
|
||||||
// long token because the broker resolves by token — swapping CLI to short
|
// when available (short, easy to paste); otherwise falls back to the
|
||||||
// codes is part of the v2 protocol, not this URL-shortener change.
|
// shortUrl, which the CLI also accepts as an argument.
|
||||||
const primaryUrl = result.shortUrl ?? result.joinUrl;
|
const primaryUrl = result.shortUrl ?? result.joinUrl;
|
||||||
const cliCmd = `claudemesh join ${result.token}`;
|
const cliArg = result.code ?? result.shortUrl ?? "";
|
||||||
|
const cliCmd = `claudemesh join ${cliArg}`;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{result.sentToEmail && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="border-primary/30 bg-primary/5 text-foreground flex items-start gap-3 rounded-lg border p-4 text-sm"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-primary mt-0.5">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
Invite sent to {result.sentToEmail}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
Email delivery is stubbed in v0.1.x — the invite is valid.
|
||||||
|
Share the link directly if needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="rounded-lg border p-6">
|
<div className="rounded-lg border p-6">
|
||||||
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
|
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
|
||||||
<div className="flex items-start justify-center">
|
<div className="flex items-start justify-center">
|
||||||
@@ -140,14 +231,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
|||||||
>
|
>
|
||||||
{copied === "cli" ? "Copied ✓" : "Copy CLI command"}
|
{copied === "cli" ? "Copied ✓" : "Copy CLI command"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={resetAll}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setResult(null);
|
|
||||||
form.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Generate another
|
Generate another
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,101 +253,196 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const ModeToggle = () => (
|
||||||
<Form {...form}>
|
<div
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-md space-y-5">
|
role="group"
|
||||||
<p className="text-muted-foreground text-sm">
|
aria-label="Invite delivery mode"
|
||||||
One-time invite for a new member. Valid for 7 days.
|
className="bg-muted inline-flex rounded-md p-1 text-sm"
|
||||||
</p>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={mode === "link"}
|
||||||
|
onClick={() => setMode("link")}
|
||||||
|
className={`focus-visible:ring-ring rounded px-3 py-1.5 font-medium transition focus-visible:outline-none focus-visible:ring-2 ${
|
||||||
|
mode === "link"
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={mode === "email"}
|
||||||
|
onClick={() => setMode("email")}
|
||||||
|
className={`focus-visible:ring-ring rounded px-3 py-1.5 font-medium transition focus-visible:outline-none focus-visible:ring-2 ${
|
||||||
|
mode === "email"
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Advanced options — hidden by default. Defaults ship 90% of users. */}
|
// Advanced block is rendered against whichever form is active. Because the
|
||||||
<div className="rounded-md border border-dashed">
|
// two schemas share identical role/maxUses/expiresInDays shapes, the field
|
||||||
<button
|
// components are structurally the same — we just bind to the active form.
|
||||||
type="button"
|
const AdvancedBlock = () => (
|
||||||
onClick={() => setShowAdvanced((s) => !s)}
|
<div className="rounded-md border border-dashed">
|
||||||
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between px-3 py-2 text-xs uppercase tracking-wider"
|
<button
|
||||||
aria-expanded={showAdvanced}
|
type="button"
|
||||||
>
|
onClick={() => setShowAdvanced((s) => !s)}
|
||||||
<span>Advanced</span>
|
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between px-3 py-2 text-xs uppercase tracking-wider"
|
||||||
<span aria-hidden="true">{showAdvanced ? "−" : "+"}</span>
|
aria-expanded={showAdvanced}
|
||||||
</button>
|
>
|
||||||
{showAdvanced && (
|
<span>Advanced</span>
|
||||||
<div className="space-y-4 border-t px-3 py-4">
|
<span aria-hidden="true">{showAdvanced ? "−" : "+"}</span>
|
||||||
<FormField
|
</button>
|
||||||
control={form.control}
|
{showAdvanced && (
|
||||||
name="role"
|
<div className="space-y-4 border-t px-3 py-4">
|
||||||
render={({ field }) => (
|
<FormField
|
||||||
<FormItem>
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
<FormLabel>Role</FormLabel>
|
control={activeForm.control as any}
|
||||||
<Select
|
name="role"
|
||||||
onValueChange={field.onChange}
|
render={({ field }) => (
|
||||||
defaultValue={field.value}
|
<FormItem>
|
||||||
>
|
<FormLabel>Role</FormLabel>
|
||||||
<FormControl>
|
<Select
|
||||||
<SelectTrigger>
|
onValueChange={field.onChange}
|
||||||
<SelectValue />
|
defaultValue={field.value}
|
||||||
</SelectTrigger>
|
>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<SelectContent>
|
<SelectTrigger>
|
||||||
<SelectItem value="member">Member</SelectItem>
|
<SelectValue />
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
</SelectTrigger>
|
||||||
</SelectContent>
|
</FormControl>
|
||||||
</Select>
|
<SelectContent>
|
||||||
<FormMessage />
|
<SelectItem value="member">Member</SelectItem>
|
||||||
</FormItem>
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
)}
|
</SelectContent>
|
||||||
/>
|
</Select>
|
||||||
<FormField
|
<FormMessage />
|
||||||
control={form.control}
|
</FormItem>
|
||||||
name="maxUses"
|
)}
|
||||||
render={({ field }) => (
|
/>
|
||||||
<FormItem>
|
<FormField
|
||||||
<FormLabel>Max uses</FormLabel>
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
<FormControl>
|
control={activeForm.control as any}
|
||||||
<Input
|
name="maxUses"
|
||||||
type="number"
|
render={({ field }) => (
|
||||||
min={1}
|
<FormItem>
|
||||||
max={1000}
|
<FormLabel>Max uses</FormLabel>
|
||||||
{...field}
|
<FormControl>
|
||||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
<Input
|
||||||
/>
|
type="number"
|
||||||
</FormControl>
|
min={1}
|
||||||
<FormMessage />
|
max={1000}
|
||||||
</FormItem>
|
{...field}
|
||||||
)}
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
<FormField
|
</FormControl>
|
||||||
control={form.control}
|
<FormMessage />
|
||||||
name="expiresInDays"
|
</FormItem>
|
||||||
render={({ field }) => (
|
)}
|
||||||
<FormItem>
|
/>
|
||||||
<FormLabel>Expires in (days)</FormLabel>
|
<FormField
|
||||||
<FormControl>
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
<Input
|
control={activeForm.control as any}
|
||||||
type="number"
|
name="expiresInDays"
|
||||||
min={1}
|
render={({ field }) => (
|
||||||
max={365}
|
<FormItem>
|
||||||
{...field}
|
<FormLabel>Expires in (days)</FormLabel>
|
||||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
<FormControl>
|
||||||
/>
|
<Input
|
||||||
</FormControl>
|
type="number"
|
||||||
<FormMessage />
|
min={1}
|
||||||
</FormItem>
|
max={365}
|
||||||
)}
|
{...field}
|
||||||
/>
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
</div>
|
/>
|
||||||
)}
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{form.formState.errors.root && (
|
return (
|
||||||
<p className="text-destructive text-sm">
|
<div className="max-w-md space-y-5">
|
||||||
{form.formState.errors.root.message}
|
<ModeToggle />
|
||||||
</p>
|
|
||||||
)}
|
{mode === "link" ? (
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
<Form {...linkForm}>
|
||||||
{form.formState.isSubmitting ? "Generating…" : "Generate invite"}
|
<form
|
||||||
</Button>
|
onSubmit={linkForm.handleSubmit(onSubmitLink)}
|
||||||
</form>
|
className="space-y-5"
|
||||||
</Form>
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
One-time invite for a new member. Valid for 7 days.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AdvancedBlock />
|
||||||
|
|
||||||
|
{linkForm.formState.errors.root && (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{linkForm.formState.errors.root.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" disabled={linkForm.formState.isSubmitting}>
|
||||||
|
{linkForm.formState.isSubmitting
|
||||||
|
? "Generating…"
|
||||||
|
: "Generate invite"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<Form {...emailForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={emailForm.handleSubmit(onSubmitEmail)}
|
||||||
|
className="space-y-5"
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Send a one-time invite directly to an email address. Valid for 7
|
||||||
|
days.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={emailForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="teammate@company.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdvancedBlock />
|
||||||
|
|
||||||
|
{emailForm.formState.errors.root && (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{emailForm.formState.errors.root.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" disabled={emailForm.formState.isSubmitting}>
|
||||||
|
{emailForm.formState.isSubmitting ? "Sending…" : "Send invite"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user