Add group management and event-specific invitation onboarding
This commit is contained in:
@@ -74,13 +74,16 @@ export function GroupPeople({
|
||||
<p className="text-xs text-muted-foreground">{member.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{member.role === "owner" ? "Owner" : "Member"}</Badge>
|
||||
{canManage ? <Select value={member.role} disabled={setMember.isPending || remove.isPending} onValueChange={value => { if (window.confirm(`Change this member's group role to ${value}?`)) setMember.mutate({ groupId, userId: member.userId, role: value as GroupRole }); }}>
|
||||
<SelectTrigger aria-label={`Group role for ${member.name}`}><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectGroup><SelectItem value="member">Member</SelectItem><SelectItem value="owner">Owner</SelectItem></SelectGroup></SelectContent>
|
||||
</Select> : <Badge variant="secondary">{member.role === "owner" ? "Owner" : "Member"}</Badge>}
|
||||
{canManage ? (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={remove.isPending || setMember.isPending}
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
remove.mutate({ groupId, userId: member.userId })
|
||||
window.confirm("Remove this member from the group and all its events?") && remove.mutate({ groupId, userId: member.userId })
|
||||
}
|
||||
>
|
||||
Remove
|
||||
@@ -150,7 +153,7 @@ export function GroupPeople({
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{canManage ? <PendingInvites groupId={groupId} /> : null}
|
||||
{canManage ? <PendingInvites groupId={groupId} canRevoke /> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SaveIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/trpc/react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
import { Field, FieldGroup, FieldLabel, FieldDescription } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function GroupSettings({ groupId, initialName }: { groupId: string; initialName: string }) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [saved, setSaved] = useState(initialName);
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const rename = api.group.rename.useMutation({
|
||||
onSuccess: async result => {
|
||||
setSaved(result.name); setName(result.name); toast.success("Group updated");
|
||||
await Promise.all([utils.group.invalidate(), utils.viewer.invalidate()]);
|
||||
router.refresh();
|
||||
},
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
return <Card>
|
||||
<CardHeader><CardTitle>Group settings</CardTitle><CardDescription>Manage the shared workspace for your team and events.</CardDescription></CardHeader>
|
||||
<CardContent><form onSubmit={event => { event.preventDefault(); rename.mutate({ groupId, name }); }}>
|
||||
<FieldGroup>
|
||||
<Field><FieldLabel htmlFor="group-name">Group name</FieldLabel><Input id="group-name" required maxLength={100} value={name} disabled={rename.isPending} onChange={event => setName(event.target.value)} /><FieldDescription>Renaming does not change event links, invite codes, or access.</FieldDescription></Field>
|
||||
<Field orientation="horizontal"><Button disabled={rename.isPending || !name.trim() || name.trim() === saved} type="submit"><SaveIcon data-icon="inline-start" />{rename.isPending ? "Saving…" : "Save group"}</Button></Field>
|
||||
</FieldGroup>
|
||||
</form></CardContent>
|
||||
</Card>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createServerCaller } from "@/trpc/server";
|
||||
import { GroupPeople } from "./group-people";
|
||||
import { GroupSettings } from "./group-settings";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -30,7 +31,9 @@ export default async function DashboardPeoplePage() {
|
||||
Shared members of {group.name}. Event access is still assigned per event.
|
||||
</p>
|
||||
</div>
|
||||
{group.permissions.includes("group.manage") ? <GroupSettings key={group.id} groupId={group.id} initialName={group.name} /> : null}
|
||||
<GroupPeople
|
||||
key={group.id}
|
||||
groupId={group.id}
|
||||
canManage={group.permissions.includes("group.people.manage")}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/server/auth";
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export const metadata = { title: "Invitation", robots: { index: false, follow: false }, referrer: "no-referrer" as const };
|
||||
|
||||
export default async function InvitationPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -37,25 +38,26 @@ export default async function InvitationPage({
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
redirect(`/sign-up?invite=${encodeURIComponent(token)}`);
|
||||
}
|
||||
const callback = `/invitations/${encodeURIComponent(token)}`;
|
||||
const role = preview.eventRole ?? preview.groupRole;
|
||||
const roleLabel = role ? role.charAt(0).toUpperCase() + role.slice(1) : "team member";
|
||||
|
||||
return (
|
||||
<main className="page-pad mx-auto max-w-md py-16">
|
||||
<Card>
|
||||
{preview.bannerUrl ? <img src={preview.bannerUrl} alt="" width={1200} height={450} className="aspect-[8/3] w-full rounded-t-xl object-cover" /> : null}
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-semibold tracking-tight">Join this event</CardTitle>
|
||||
<CardTitle className="text-2xl font-semibold tracking-tight">{preview.eventTitle ? `Help out with ${preview.eventTitle}` : `Join ${preview.groupName ?? "the team"}`}</CardTitle>
|
||||
<CardDescription>
|
||||
{preview.eventRole
|
||||
? `You'll join as event ${preview.eventRole}.`
|
||||
: preview.groupRole
|
||||
? `You'll join the group as ${preview.groupRole}.`
|
||||
: "This invite grants access."}
|
||||
You’ve been invited to join as a {roleLabel}. {session ? "Accept below to join the team." : "Create an account or sign in to accept your invitation. Use the email address that received the invite."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<RedeemInviteButton token={token} />
|
||||
<p className="text-sm text-muted-foreground">This invitation grants team access, not a guest RSVP. Your assigned role controls what you can view and manage.</p>
|
||||
{session ? <RedeemInviteButton token={token} /> : <>
|
||||
<Button asChild><Link href={`/sign-up?invite=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callback)}`}>Create account to accept</Link></Button>
|
||||
<Button asChild variant="outline"><Link href={`/sign-in?callbackURL=${encodeURIComponent(callback)}`}>Already have an account? Sign in</Link></Button>
|
||||
</>}
|
||||
<Button asChild variant="ghost">
|
||||
<Link href="/dashboard">Skip</Link>
|
||||
</Button>
|
||||
|
||||
@@ -17,7 +17,8 @@ export default async function SignUpPage({
|
||||
searchParams: Promise<{ callbackURL?: string; invite?: string; code?: string }>;
|
||||
}) {
|
||||
const { callbackURL, invite, code } = await searchParams;
|
||||
const token = invite ?? code;
|
||||
const callbackInvite = callbackURL?.match(/^\/invitations\/([^/?#]+)$/)?.[1];
|
||||
const token = invite ?? code ?? (callbackInvite ? decodeURIComponent(callbackInvite) : undefined);
|
||||
const safeCallback =
|
||||
callbackURL?.startsWith("/") && !callbackURL.startsWith("//")
|
||||
? callbackURL
|
||||
@@ -34,7 +35,7 @@ export default async function SignUpPage({
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-semibold tracking-tight">Create an account</CardTitle>
|
||||
<CardDescription>
|
||||
{settings.openSignup
|
||||
{token ? "Create your account with the email address that received the invitation. You’ll return to accept it next." : settings.openSignup
|
||||
? "Host an event and share a guest upload link."
|
||||
: "This deployment is invite-only. Use a code or invite link."}
|
||||
</CardDescription>
|
||||
|
||||
@@ -96,7 +96,7 @@ export function SignUpForm({
|
||||
/>
|
||||
<FieldDescription>Use at least 10 characters in production.</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
{initialCode.length > 4 ? <p className="text-sm text-muted-foreground">Your invitation is attached. You’ll confirm your access after creating your account.</p> : <Field>
|
||||
<FieldLabel htmlFor="invite">
|
||||
Invite code {requireInvite ? "" : "(optional)"}
|
||||
</FieldLabel>
|
||||
@@ -115,7 +115,7 @@ export function SignUpForm({
|
||||
className="tap-target"
|
||||
required={requireInvite}
|
||||
/>
|
||||
</Field>
|
||||
</Field>}
|
||||
</FieldGroup>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
|
||||
Reference in New Issue
Block a user