Skip to content

Commit a713b62

Browse files
author
Peter Nguyen
committed
frontend the invite page, will now show error log, for example, user already exists etc
1 parent 19259bc commit a713b62

5 files changed

Lines changed: 138 additions & 51 deletions

File tree

frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { Button } from "@/components/ui/button";
44
import { ApiError } from "@/lib/api";
55
import { acceptInvite, InviteDetails } from "@/models/invite";
66
import Link from "next/link";
7-
import { useState } from "react";
7+
import { useEffect, useState } from "react";
8+
import { useRouter } from "next/navigation";
89

910
type Props = {
1011
code: string;
@@ -16,6 +17,17 @@ type Props = {
1617
export default function InviteClient({ code, invite, dict, mockMode = false }: Props) {
1718
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
1819
const [message, setMessage] = useState<string | null>(null);
20+
const router = useRouter();
21+
22+
// After successful acceptance, redirect after a short delay.
23+
useEffect(() => {
24+
if (status === "success") {
25+
const timer = setTimeout(() => {
26+
router.push("/dashboard");
27+
}, 2000);
28+
return () => clearTimeout(timer);
29+
}
30+
}, [status, router]);
1931

2032
const handleAccept = async () => {
2133
setStatus("loading");
@@ -29,14 +41,18 @@ export default function InviteClient({ code, invite, dict, mockMode = false }: P
2941
} catch (err) {
3042
setStatus("error");
3143
if (err instanceof ApiError) {
32-
setMessage(err.message);
44+
if (err.status === 401) {
45+
setMessage(dict.dashboard.invite.login_required ?? "Please log in to accept this invite.");
46+
} else {
47+
setMessage(err.message);
48+
}
3349
} else {
3450
setMessage("Something went wrong. Please try again.");
3551
}
3652
}
3753
};
3854

39-
const inviteInvalid = invite.expired || invite.used;
55+
const inviteInvalid = invite.expired || invite.used || status === "success";
4056

4157
return (
4258
<div className="max-w-xl mx-auto p-6 flex flex-col gap-4">
Lines changed: 15 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,23 @@
1-
"use client";
2-
3-
import type { InviteDetails } from "@/models/invite";
4-
import { useParams } from "next/navigation";
1+
import { getDictionary } from "@/app/[lang]/dictionaries";
2+
import { getInvite } from "@/models/invite";
3+
import { notFound } from "next/navigation";
54
import InviteClient from "./invite-client";
65

7-
export default function Page() {
8-
const params = useParams<{ lang?: string; inviteId?: string }>();
9-
const code = params?.inviteId ?? "mock-invite-code";
6+
type Params = Promise<{ lang: string; inviteId: string }>;
7+
8+
export default async function Page({ params }: { params: Params }) {
9+
const { lang, inviteId } = await params;
1010

11-
const invite: InviteDetails = {
12-
organisation_id: "1",
13-
organisation_name: "Chaos Demo Org",
14-
email: "invited.user@example.com",
15-
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(),
16-
used: false,
17-
expired: false,
18-
};
11+
const dict = await getDictionary(lang);
1912

20-
// Minimal dict mock so the UI renders without pulling translations.
21-
const dict = {
22-
dashboard: {
23-
invite: {
24-
title: "Organisation invite",
25-
invited_by: "You’ve been invited to join {org}.",
26-
sent_to: "Invite sent to",
27-
expired: "This invite has expired.",
28-
used: "This invite has already been used.",
29-
login_cta: "Log in",
30-
accept_cta: "Accept invite",
31-
accepted: "Invite accepted (mock).",
32-
wrong_account: "Not you? Log in with a different account.",
33-
},
34-
},
35-
};
13+
let invite;
14+
try {
15+
invite = await getInvite(inviteId);
16+
} catch {
17+
return notFound();
18+
}
3619

37-
return <InviteClient code={code} invite={invite} dict={dict} mockMode />;
20+
return <InviteClient code={inviteId} invite={invite} dict={dict} />;
3821
}
3922

4023

frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/members/members.tsx

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { DataTable } from "@/components/ui/data-table";
2020
import { Input } from "@/components/ui/input";
2121
import { Label } from "@/components/ui/label";
2222
import { useState } from "react";
23+
import { ApiError } from "@/lib/api";
2324

2425
export default function OrganisationMembers({ orgId, dict }: { orgId: string, dict: any }) {
2526
const queryClient = useQueryClient();
@@ -59,14 +60,40 @@ export function AddMemberDialog({ orgId, dict }: { orgId: string, dict: any }) {
5960
const queryClient = useQueryClient();
6061

6162
const [email, setEmail] = useState("");
63+
const [open, setOpen] = useState(false);
64+
const [status, setStatus] = useState<"idle" | "loading">("idle");
65+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
6266

6367
const handleInviteMember = async () => {
64-
await inviteOrganisationUser(orgId, email);
65-
await queryClient.invalidateQueries({ queryKey: [`${orgId}-members`] });
68+
setErrorMessage(null);
69+
70+
const trimmed = email.trim();
71+
if (!trimmed) {
72+
setErrorMessage(dict?.common?.email_required ?? "Email is required.");
73+
return;
74+
}
75+
76+
setStatus("loading");
77+
try {
78+
await inviteOrganisationUser(orgId, trimmed);
79+
await queryClient.invalidateQueries({ queryKey: [`${orgId}-members`] });
80+
setEmail("");
81+
setOpen(false);
82+
} catch (err) {
83+
if (err instanceof ApiError) {
84+
setErrorMessage(err.message);
85+
} else if (err instanceof Error) {
86+
setErrorMessage(err.message);
87+
} else {
88+
setErrorMessage("Failed to invite member.");
89+
}
90+
} finally {
91+
setStatus("idle");
92+
}
6693
}
6794

6895
return (
69-
<Dialog>
96+
<Dialog open={open} onOpenChange={setOpen}>
7097
<DialogTrigger asChild>
7198
<Button variant="ghost" className="mr-1"><Plus className="w-8 h-8" /> </Button>
7299
</DialogTrigger>
@@ -77,10 +104,18 @@ export function AddMemberDialog({ orgId, dict }: { orgId: string, dict: any }) {
77104
<div className="flex flex-col gap-2">
78105
<p className="text-xs text-muted-foreground flex items-center gap-1">{dict.dashboard.members.admin_edit_block_description}</p>
79106
<Label>{dict.common.email}</Label>
80-
<Input onChange={(e) => setEmail(e.target.value)} />
107+
<Input value={email} onChange={(e) => setEmail(e.target.value)} />
108+
{errorMessage && (
109+
<p className="text-xs text-red-600">{errorMessage}</p>
110+
)}
81111
</div>
82112
<DialogFooter>
83-
<Button onClick={async () => await handleInviteMember()}>{dict.dashboard.actions.invite}</Button>
113+
<Button
114+
onClick={handleInviteMember}
115+
disabled={status === "loading"}
116+
>
117+
{status === "loading" ? (dict?.common?.loading ?? "Loading...") : dict.dashboard.actions.invite}
118+
</Button>
84119
</DialogFooter>
85120
</DialogContent>
86121
</Dialog>

frontend-nextjs/src/lib/api.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,23 +71,63 @@ export async function apiRequest<T>(
7171
const response = await fetch(url, fetchOptions);
7272

7373
if (!response.ok) {
74-
if (response.status === 401 || okRequiredOtherwiseLogin) {
75-
if (isServer) {
76-
const { redirect } = await import("next/navigation");
77-
const { headers } = await import("next/headers");
78-
const headersList = await headers();
79-
const pathname = headersList.get("x-pathname") || "/";
80-
81-
redirect(`/login?to=${encodeURIComponent(pathname)}`);
74+
// Best-effort parse of backend error payloads like `{ "error": "..." }`
75+
// Use `response.clone()` so we don't consume the original body if needed elsewhere.
76+
let serverMessage: string | undefined;
77+
try {
78+
const cloned = response.clone();
79+
const contentType = cloned.headers.get("content-type") || "";
80+
if (contentType.includes("application/json")) {
81+
const parsed = (await cloned.json()) as unknown;
82+
if (
83+
parsed &&
84+
typeof parsed === "object" &&
85+
"error" in parsed &&
86+
typeof (parsed as { error?: unknown }).error === "string"
87+
) {
88+
serverMessage = (parsed as { error: string }).error;
89+
} else if (
90+
parsed &&
91+
typeof parsed === "object" &&
92+
"message" in parsed &&
93+
typeof (parsed as { message?: unknown }).message === "string"
94+
) {
95+
serverMessage = (parsed as { message: string }).message;
96+
}
8297
} else {
83-
window.location.href = `/login?to=${encodeURIComponent(window.location.pathname)}`;
98+
const text = await cloned.text();
99+
// Some servers mislabel JSON error responses; try JSON parse anyway.
100+
try {
101+
const parsed = JSON.parse(text) as unknown;
102+
if (
103+
parsed &&
104+
typeof parsed === "object" &&
105+
"error" in parsed &&
106+
typeof (parsed as { error?: unknown }).error === "string"
107+
) {
108+
serverMessage = (parsed as { error: string }).error;
109+
} else if (
110+
parsed &&
111+
typeof parsed === "object" &&
112+
"message" in parsed &&
113+
typeof (parsed as { message?: unknown }).message === "string"
114+
) {
115+
serverMessage = (parsed as { message: string }).message;
116+
} else {
117+
serverMessage = text;
118+
}
119+
} catch {
120+
serverMessage = text;
121+
}
84122
}
123+
} catch {
124+
// ignore parse errors
85125
}
86126

87127
throw new ApiError(
88128
response.status,
89129
response.statusText,
90-
`API request failed: ${method} ${path}`
130+
serverMessage || `API request failed: ${method} ${path}`
91131
);
92132
}
93133

frontend-nextjs/src/models/invite.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,23 @@ export type InviteDetails = {
1010
expired: boolean;
1111
};
1212

13+
type InviteResponse = { message: InviteDetails };
14+
15+
/**
16+
* Gets the invite details for a given invite code.
17+
* @param code - The invite code
18+
* @returns The invite details
19+
*/
1320
export async function getInvite(code: string): Promise<InviteDetails> {
14-
return await apiRequest<InviteDetails>(`/api/v1/invite/${code}`);
21+
const res = await apiRequest<InviteResponse>(`/api/v1/invite/${code}`);
22+
return res.message;
1523
}
1624

25+
/**
26+
* Accepts an invite for the current authenticated user.
27+
* @param code - The invite code
28+
* @returns The app message
29+
*/
1730
export async function acceptInvite(code: string): Promise<AppMessage> {
1831
return await apiRequest<AppMessage>(`/api/v1/invite/${code}`, {
1932
method: "POST",

0 commit comments

Comments
 (0)