Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 29 additions & 13 deletions src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,50 @@
"use client";

import { useState, useTransition } from "react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import FormFieldWrapper from "@/components/forms/FormFieldWrapper";
import { Link } from "@/components/Link";
import { Button } from "@/shadcn/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shadcn/ui/card";
import { Input } from "@/shadcn/ui/input";
import { Separator } from "@/shadcn/ui/separator";
import { login } from "./actions";
import type { SyntheticEvent } from "react";

export default function LoginPage() {
const [error, setError] = useState("");
const [isPending, setIsPending] = useState(false);
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirectTo");

const [isPending, startTransition] = useTransition();

// Don't use a server action here as setting cookies in a server action
// causes the full component tree to re-render, which (a) is unnecessary here
// and (b) interferes with the client-side redirect post login. The redirect
// should be client side as this is the best way to ensure fresh state.
const onSubmitLogin = async (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setError("");
setIsPending(true);
const formData = new FormData(e.currentTarget);
startTransition(async () => {
const error = await login(formData);
if (error) {
setError(error);
} else {
// Use a browser-level redirection here to force a full page reload.
// This is a good idea after auth changes, as it clears client-side state.
window.location.href = "/";
try {
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: formData.get("email"),
password: formData.get("password"),
}),
});
const data = (await response.json()) as { error?: string };
if (!response.ok) {
setError(data.error || "Failed to log in");
setIsPending(false);
return;
}
});
window.location.href = redirectTo || "/";
} catch {
setError("Failed to log in");
setIsPending(false);
}
};

return (
Expand Down
109 changes: 109 additions & 0 deletions src/app/(private)/account/components/InviteMemberDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { PlusIcon } from "lucide-react";
import { type FormEvent, useState } from "react";
import { toast } from "sonner";
import FormFieldWrapper from "@/components/forms/FormFieldWrapper";
import { useOrganisations } from "@/hooks/useOrganisations";
import { useTRPC } from "@/services/trpc/react";
import { Button } from "@/shadcn/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shadcn/ui/dialog";
import { Input } from "@/shadcn/ui/input";

export default function InviteMemberDialog() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { currentOrganisation } = useOrganisations();

const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");

const { mutate: inviteMember, isPending } = useMutation(
trpc.organisation.inviteMember.mutationOptions({
onSuccess: () => {
toast.success("Invitation sent", {
description: `An invite has been sent to ${email}`,
});
setName("");
setEmail("");
setDialogOpen(false);
Comment on lines +32 to +38
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The form clears name and email fields and closes the dialog on success, but if the user opens the dialog again and starts typing, they might submit a partial/incorrect invitation if they accidentally trigger the form submission. Consider resetting the form state when the dialog is opened (not just when it's successfully submitted) to prevent stale data from being accidentally submitted.

Copilot uses AI. Check for mistakes.
queryClient.invalidateQueries({
queryKey: trpc.organisation.listUsers.queryKey(),
});
},
onError: (error) => {
toast.error("Failed to send invitation", {
description: error.message,
});
},
}),
);

if (!currentOrganisation) {
return null;
}

const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
inviteMember({
organisationId: currentOrganisation.id,
name,
email,
});
};

return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="mt-4">
<PlusIcon className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Invite Member</DialogTitle>
<DialogDescription>
Send an invitation to join {currentOrganisation.name}.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<FormFieldWrapper id="invite-name" label="Name">
<Input
id="invite-name"
name="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</FormFieldWrapper>

<FormFieldWrapper id="invite-email" label="Email">
<Input
id="invite-email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</FormFieldWrapper>

<Button disabled={isPending} type="submit" size="sm" className="mt-2">
{isPending ? "Sending…" : "Send invitation"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
77 changes: 77 additions & 0 deletions src/app/(private)/account/components/OrganisationUsersTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { useOrganisations } from "@/hooks/useOrganisations";
import { useTRPC } from "@/services/trpc/react";
import { Avatar, AvatarFallback, AvatarImage } from "@/shadcn/ui/avatar";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shadcn/ui/table";

function getInitials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
}

export default function OrganisationUsersTable() {
const trpc = useTRPC();
const { currentOrganisation } = useOrganisations();

const { data: users, isLoading } = useQuery(
trpc.organisation.listUsers.queryOptions(
{ organisationId: currentOrganisation?.id ?? "" },
{ enabled: !!currentOrganisation },
),
);

if (!currentOrganisation) {
return null;
}

if (isLoading) {
return <p className="text-sm text-muted-foreground">Loading members…</p>;
}

if (!users?.length) {
return <p className="text-sm text-muted-foreground">No members found.</p>;
}

return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<Avatar className="h-8 w-8">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} />}
<AvatarFallback className="text-xs">
{getInitials(user.name || user.email)}
</AvatarFallback>
</Avatar>
</TableCell>
<TableCell>{user.name || "-"}</TableCell>
<TableCell className="text-muted-foreground">
{user.email}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
114 changes: 114 additions & 0 deletions src/app/(private)/account/components/PendingInvitationsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use client";

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/services/trpc/react";
import { Button } from "@/shadcn/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shadcn/ui/table";

export default function PendingInvitationsTable() {
const trpc = useTRPC();
const queryClient = useQueryClient();

const { data: invitations, isLoading } = useQuery(
trpc.invitation.listForUser.queryOptions(),
);

const { mutate: acceptInvite, isPending: isAccepting } = useMutation(
trpc.organisation.acceptInvite.mutationOptions({
onSuccess: () => {
toast.success("Invitation accepted!");
queryClient.invalidateQueries({
queryKey: trpc.organisation.list.queryKey(),
});
queryClient.invalidateQueries({
queryKey: trpc.invitation.listForUser.queryKey(),
});
},
onError: (error) => {
toast.error("Failed to accept invitation", {
description: error.message,
});
},
}),
);

const { mutate: rejectInvite, isPending: isRejecting } = useMutation(
trpc.organisation.rejectInvite.mutationOptions({
onSuccess: () => {
toast.success("Invitation declined");
queryClient.invalidateQueries({
queryKey: trpc.invitation.listForUser.queryKey(),
});
},
onError: (error) => {
toast.error("Failed to decline invitation", {
description: error.message,
});
},
}),
);

const isPending = isAccepting || isRejecting;
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isPending state is shared between accept and reject actions. This means that if a user clicks "Accept" on one invitation and "Reject" on another before the first completes, both buttons will be disabled. This could lead to confusion. Consider tracking pending state per invitation ID instead of globally.

Copilot uses AI. Check for mistakes.

if (isLoading) {
return (
<p className="text-sm text-muted-foreground">Loading invitations…</p>
);
}

if (!invitations?.length) {
return (
<p className="text-sm text-muted-foreground">No pending invitations.</p>
);
}

return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Organisation</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitations.map((invitation) => (
<TableRow key={invitation.id}>
<TableCell className="font-medium">
{invitation.organisationName ?? "Unknown"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
onClick={() => acceptInvite({ invitationId: invitation.id })}
disabled={isPending}
>
<CheckIcon className="mr-1 h-3 w-3" />
{isAccepting ? "Accepting…" : "Accept"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => rejectInvite({ invitationId: invitation.id })}
disabled={isPending}
>
<XIcon className="mr-1 h-3 w-3" />
{isRejecting ? "Declining…" : "Decline"}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
2 changes: 1 addition & 1 deletion src/app/(private)/account/components/UserSettingsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export default function UserSettingsForm() {
{(field) => (
<div className="space-y-2">
<AvatarInput
name={currentUser?.name || ""}
name={currentUser?.name || currentUser?.email || ""}
src={field.state.value}
onChange={field.handleChange}
/>
Expand Down
Loading