mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
Two bugs prevented the Lark binding flow from completing for already-logged-in users: 1. The useEffect ran before AuthInitializer's getMe() returned, setting state to needs-auth; the guard then blocked re-entry once auth loaded. 2. The sign-in redirect used ?redirect= but the login page reads ?next=. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
141 lines
5.0 KiB
TypeScript
141 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { api } from "@multica/core/api";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import { useNavigation } from "../navigation";
|
|
import { useT } from "../i18n";
|
|
|
|
type RedeemState =
|
|
| { kind: "idle" }
|
|
| { kind: "redeeming" }
|
|
| { kind: "done"; workspaceId: string; installationId: string }
|
|
| { kind: "needs-auth" }
|
|
| { kind: "error"; reason: string };
|
|
|
|
// LarkBindPage is the destination the Bot's "you need to bind" reply
|
|
// card points at. The user lands here logged out OR logged in; we
|
|
// require auth before redeeming because the redeemer's Multica
|
|
// identity is taken from the session (the token alone never proves
|
|
// who is binding — see lark.BindingTokenService.RedeemAndBind).
|
|
//
|
|
// The token comes in via `?token=<raw>`. We POST it to
|
|
// /api/lark/binding/redeem; the backend returns 410 (invalid/expired),
|
|
// 409 (already bound to another user), 403 (not a workspace member)
|
|
// or 200 with the bound installation. Each maps to distinct user-
|
|
// facing copy via lark_bind in common.json.
|
|
export function LarkBindPage({ token }: { token: string | null }) {
|
|
const { t } = useT("common");
|
|
const user = useAuthStore((s) => s.user);
|
|
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
|
const navigation = useNavigation();
|
|
const [state, setState] = useState<RedeemState>({ kind: "idle" });
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setState({ kind: "error", reason: "missing_token" });
|
|
return;
|
|
}
|
|
if (isAuthLoading) return;
|
|
if (!user) {
|
|
setState({ kind: "needs-auth" });
|
|
return;
|
|
}
|
|
if (state.kind !== "idle" && state.kind !== "needs-auth") return;
|
|
setState({ kind: "redeeming" });
|
|
(async () => {
|
|
try {
|
|
const resp = await api.redeemLarkBindingToken(token);
|
|
setState({
|
|
kind: "done",
|
|
workspaceId: resp.workspace_id,
|
|
installationId: resp.installation_id,
|
|
});
|
|
} catch (e) {
|
|
setState({
|
|
kind: "error",
|
|
reason: redemptionFailureReason(e),
|
|
});
|
|
}
|
|
})();
|
|
}, [token, user, isAuthLoading, state.kind]);
|
|
|
|
return (
|
|
<div className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center p-6">
|
|
<Card className="w-full">
|
|
<CardContent className="space-y-4">
|
|
<h1 className="text-lg font-semibold">{t(($) => $.lark_bind.page_title)}</h1>
|
|
{state.kind === "idle" || state.kind === "redeeming" ? (
|
|
<p className="text-sm text-muted-foreground">{t(($) => $.lark_bind.redeeming)}</p>
|
|
) : state.kind === "needs-auth" ? (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t(($) => $.lark_bind.needs_auth_description)}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
onClick={() =>
|
|
navigation.push(
|
|
`/login?next=${encodeURIComponent(
|
|
`/lark/bind?token=${encodeURIComponent(token ?? "")}`,
|
|
)}`,
|
|
)
|
|
}
|
|
>
|
|
{t(($) => $.lark_bind.sign_in)}
|
|
</Button>
|
|
</>
|
|
) : state.kind === "done" ? (
|
|
<>
|
|
<p className="text-sm font-medium">{t(($) => $.lark_bind.done_title)}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(($) => $.lark_bind.done_description)}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="text-sm font-medium">{t(($) => $.lark_bind.error_title)}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{(() => {
|
|
switch (state.reason) {
|
|
case "missing_token":
|
|
return t(($) => $.lark_bind.error_missing_token);
|
|
case "expired":
|
|
return t(($) => $.lark_bind.error_expired);
|
|
case "already_bound":
|
|
return t(($) => $.lark_bind.error_already_bound);
|
|
case "not_member":
|
|
return t(($) => $.lark_bind.error_not_member);
|
|
default:
|
|
return t(($) => $.lark_bind.error_unknown);
|
|
}
|
|
})()}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t(($) => $.lark_bind.error_admin_hint)}
|
|
</p>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function redemptionFailureReason(err: unknown): string {
|
|
const msg = err instanceof Error ? err.message : "";
|
|
const lower = msg.toLowerCase();
|
|
if (lower.includes("invalid") || lower.includes("expired") || lower.includes("410")) {
|
|
return "expired";
|
|
}
|
|
if (lower.includes("already bound") || lower.includes("409")) {
|
|
return "already_bound";
|
|
}
|
|
if (lower.includes("workspace member") || lower.includes("403")) {
|
|
return "not_member";
|
|
}
|
|
return "unknown";
|
|
}
|