sign-in-methods.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. "use client";
  2. /**
  3. * Unified login methods — same weight for each path into the dashboard:
  4. * 1. Email OTP (Briven)
  5. * 2. Konnos OAuth (Briven)
  6. * 3. MetaMask (browser wallet)
  7. * 4. mavi wallet (local encrypted vault — create at /portal/mavi)
  8. */
  9. import { injected } from "@wagmi/connectors/injected";
  10. import Link from "next/link";
  11. import { useRouter } from "next/navigation";
  12. import { useCallback, useEffect, useRef, useState } from "react";
  13. import { useAccount, useConnect, useDisconnect } from "wagmi";
  14. import { usePortalAuth } from "@/components/auth/portal-auth-provider";
  15. import { Button } from "@/components/ui/button";
  16. import { ErrorBanner } from "@/components/ui/error-banner";
  17. import { Input } from "@/components/ui/input";
  18. import { PasswordInput } from "@/components/ui/password-input";
  19. import {
  20. MetaMaskLogo,
  21. MaviWalletLogo,
  22. } from "@/components/wallet/wallet-logos";
  23. import { getBrivenAuth } from "@/lib/auth";
  24. import { createDemoWalletSession } from "@/lib/demo-session";
  25. import {
  26. type LoginMethodId,
  27. readLastLoginMethod,
  28. writeLastLoginMethod,
  29. } from "@/lib/last-login";
  30. import { hasVault, unlockVault } from "@/lib/mavi/storage";
  31. import { establishPortalSession } from "@/lib/portal-session";
  32. const OTP_LEN = 6;
  33. function LastUsedBadge({ show }: { show: boolean }) {
  34. if (!show) return null;
  35. return (
  36. <span className="rounded-full border border-accent/40 bg-accent-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent-hover">
  37. Last used to sign in
  38. </span>
  39. );
  40. }
  41. export function SignInMethods({
  42. redirectTo = "/portal/dashboard",
  43. }: {
  44. redirectTo?: string;
  45. }) {
  46. const router = useRouter();
  47. const auth = getBrivenAuth();
  48. const { setDemoSession, clearDemo } = usePortalAuth();
  49. const { address, isConnected } = useAccount();
  50. const { connect, isPending, error: walletError } = useConnect();
  51. const { disconnect } = useDisconnect();
  52. const [email, setEmail] = useState("");
  53. const [otp, setOtp] = useState("");
  54. const [otpOpen, setOtpOpen] = useState(false);
  55. const [otpSent, setOtpSent] = useState(false);
  56. const [busy, setBusy] = useState(false);
  57. const [message, setMessage] = useState<string | null>(null);
  58. const [error, setError] = useState<string | null>(null);
  59. const [maviOpen, setMaviOpen] = useState(false);
  60. const [maviPassword, setMaviPassword] = useState("");
  61. const [maviHasVault, setMaviHasVault] = useState(false);
  62. const [lastUsed, setLastUsed] = useState<LoginMethodId | null>(null);
  63. const verifyingRef = useRef(false);
  64. const lastTriedRef = useRef("");
  65. useEffect(() => {
  66. setLastUsed(readLastLoginMethod());
  67. // Open email panel if that was last used
  68. if (readLastLoginMethod() === "email") setOtpOpen(true);
  69. if (readLastLoginMethod() === "mavi") setMaviOpen(true);
  70. setMaviHasVault(hasVault());
  71. }, []);
  72. const remember = useCallback((method: LoginMethodId) => {
  73. writeLastLoginMethod(method);
  74. setLastUsed(method);
  75. }, []);
  76. const goIn = useCallback(
  77. async (opts: {
  78. userId: string;
  79. method: "email" | "wallet" | "konnos";
  80. loginMethodId: LoginMethodId;
  81. email?: string;
  82. walletAddress?: string;
  83. }) => {
  84. remember(opts.loginMethodId);
  85. if (opts.method === "wallet" && opts.walletAddress) {
  86. setDemoSession(createDemoWalletSession(opts.walletAddress));
  87. }
  88. await establishPortalSession({
  89. userId: opts.userId,
  90. email: opts.email,
  91. method: opts.method,
  92. walletAddress: opts.walletAddress,
  93. });
  94. window.dispatchEvent(new Event("krypco-auth-change"));
  95. router.push(redirectTo);
  96. router.refresh();
  97. },
  98. [redirectTo, remember, router, setDemoSession],
  99. );
  100. const verifyOtp = useCallback(
  101. async (code: string) => {
  102. if (!auth) return;
  103. const cleaned = code.replace(/\s/g, "").trim();
  104. if (cleaned.length < 4) return;
  105. if (verifyingRef.current) return;
  106. if (lastTriedRef.current === cleaned) return;
  107. verifyingRef.current = true;
  108. lastTriedRef.current = cleaned;
  109. setError(null);
  110. setBusy(true);
  111. setMessage("Checking code…");
  112. try {
  113. const loginEmail = email.trim().toLowerCase();
  114. const r = await auth.signIn.otpVerify({
  115. email: loginEmail,
  116. otp: cleaned,
  117. });
  118. if (!r.ok) {
  119. setError(r.message || r.code);
  120. setMessage(null);
  121. lastTriedRef.current = "";
  122. return;
  123. }
  124. const userId =
  125. ("userId" in r && typeof r.userId === "string" && r.userId) ||
  126. `user_${loginEmail}`;
  127. setMessage("Signed in — opening dashboard…");
  128. await goIn({
  129. userId,
  130. method: "email",
  131. loginMethodId: "email",
  132. email: loginEmail,
  133. });
  134. } catch (e) {
  135. setError(e instanceof Error ? e.message : "Could not verify code");
  136. setMessage(null);
  137. lastTriedRef.current = "";
  138. } finally {
  139. setBusy(false);
  140. verifyingRef.current = false;
  141. }
  142. },
  143. [auth, email, goIn],
  144. );
  145. async function sendOtp() {
  146. if (!auth) {
  147. setError("Briven Auth is not configured.");
  148. return;
  149. }
  150. setError(null);
  151. setMessage(null);
  152. setBusy(true);
  153. try {
  154. const r = await auth.signIn.otpRequest({
  155. email: email.trim(),
  156. redirectTo,
  157. });
  158. if (!r.ok) {
  159. setError(r.message || r.code);
  160. return;
  161. }
  162. setOtpSent(true);
  163. setOtp("");
  164. lastTriedRef.current = "";
  165. setMessage("Check your email for a one-time code.");
  166. } catch (e) {
  167. setError(e instanceof Error ? e.message : "Could not send code");
  168. } finally {
  169. setBusy(false);
  170. }
  171. }
  172. function onOtpChange(raw: string) {
  173. const digits = raw.replace(/\D/g, "").slice(0, OTP_LEN);
  174. setOtp(digits);
  175. setError(null);
  176. if (digits.length === OTP_LEN) void verifyOtp(digits);
  177. }
  178. function startKonnos() {
  179. if (!auth) {
  180. setError("Briven Auth is not configured for Konnos.");
  181. return;
  182. }
  183. setError(null);
  184. remember("konnos");
  185. const { redirectUrl } = auth.signIn.social({
  186. provider: "konnos",
  187. redirectTo:
  188. typeof window !== "undefined"
  189. ? `${window.location.origin}${redirectTo}`
  190. : redirectTo,
  191. });
  192. window.location.assign(redirectUrl);
  193. }
  194. function startMetaMask() {
  195. setError(null);
  196. setMaviOpen(false);
  197. setMessage(null);
  198. connect(
  199. { connector: injected() },
  200. {
  201. onSuccess: (data) => {
  202. const addr = data.accounts[0];
  203. if (!addr) {
  204. setError("No account returned from MetaMask.");
  205. return;
  206. }
  207. setMessage("Wallet connected — opening dashboard…");
  208. void goIn({
  209. userId: `wallet_${addr.toLowerCase()}`,
  210. method: "wallet",
  211. loginMethodId: "metamask",
  212. walletAddress: addr,
  213. });
  214. },
  215. onError: (e) => {
  216. const msg = e.message || "MetaMask connection failed.";
  217. if (/provider|injected|ethereum|meta\s*mask/i.test(msg)) {
  218. setError(
  219. "MetaMask was not found. Install the MetaMask browser extension, then try again.",
  220. );
  221. } else if (/reject|denied|user/i.test(msg)) {
  222. setError("Connection was cancelled in MetaMask. You can try again.");
  223. } else {
  224. setError(msg);
  225. }
  226. },
  227. },
  228. );
  229. }
  230. function startMavi() {
  231. setError(null);
  232. setMessage(null);
  233. setMaviHasVault(hasVault());
  234. setMaviOpen((o) => !o);
  235. }
  236. async function unlockMaviAndEnter() {
  237. setError(null);
  238. if (!maviPassword) {
  239. setError("Enter your mavi password.");
  240. return;
  241. }
  242. setBusy(true);
  243. setMessage("Unlocking mavi…");
  244. try {
  245. const u = await unlockVault(maviPassword);
  246. setMessage("mavi unlocked — opening dashboard…");
  247. await goIn({
  248. userId: `mavi_${u.address.toLowerCase()}`,
  249. method: "wallet",
  250. loginMethodId: "mavi",
  251. walletAddress: u.address,
  252. });
  253. } catch (e) {
  254. setMessage(null);
  255. setError(e instanceof Error ? e.message : "mavi unlock failed");
  256. } finally {
  257. setBusy(false);
  258. }
  259. }
  260. return (
  261. <div className="space-y-4">
  262. <p className="text-sm text-muted-foreground leading-relaxed">
  263. Pick any method below. All of them take you into the same dashboard.
  264. </p>
  265. {/* 1 — Email OTP */}
  266. <div
  267. className={
  268. lastUsed === "email"
  269. ? "rounded-xl border border-accent/50 bg-card/50 p-4 ring-1 ring-accent/20"
  270. : "rounded-xl border border-border bg-card/50 p-4"
  271. }
  272. >
  273. <button
  274. type="button"
  275. className="flex w-full flex-wrap items-center justify-between gap-2 text-left"
  276. onClick={() => setOtpOpen((o) => !o)}
  277. >
  278. <span className="flex flex-wrap items-center gap-2">
  279. <span className="text-sm font-medium">Email code (OTP)</span>
  280. <LastUsedBadge show={lastUsed === "email"} />
  281. </span>
  282. <span className="text-xs text-muted-foreground">
  283. {otpOpen ? "Hide" : "Open"}
  284. </span>
  285. </button>
  286. {otpOpen ? (
  287. <div className="mt-4 space-y-3 border-t border-border pt-4">
  288. <div className="space-y-2">
  289. <label htmlFor="signin-email" className="text-sm font-medium">
  290. Email
  291. </label>
  292. <Input
  293. id="signin-email"
  294. type="email"
  295. autoComplete="email"
  296. placeholder="you@company.com"
  297. value={email}
  298. onChange={(e) => setEmail(e.target.value)}
  299. disabled={busy}
  300. />
  301. </div>
  302. <Button
  303. type="button"
  304. className="w-full"
  305. disabled={busy || !email.includes("@")}
  306. onClick={() => void sendOtp()}
  307. >
  308. {otpSent ? "Resend email code" : "Send email code"}
  309. </Button>
  310. {otpSent ? (
  311. <div className="flex flex-col items-center pt-2 text-center">
  312. <label
  313. htmlFor="signin-otp"
  314. className="mb-2 text-sm font-medium"
  315. >
  316. Code from email
  317. </label>
  318. <Input
  319. id="signin-otp"
  320. inputMode="numeric"
  321. autoComplete="one-time-code"
  322. autoFocus
  323. placeholder="······"
  324. maxLength={OTP_LEN}
  325. value={otp}
  326. onChange={(e) => onOtpChange(e.target.value)}
  327. disabled={busy}
  328. className="mx-auto h-14 w-full max-w-[16rem] text-center font-mono text-2xl tracking-[0.35em]"
  329. />
  330. <p className="mt-2 text-xs text-muted-foreground">
  331. All {OTP_LEN} digits → auto sign-in
  332. </p>
  333. </div>
  334. ) : null}
  335. </div>
  336. ) : null}
  337. </div>
  338. <div className="relative py-1">
  339. <div className="absolute inset-0 flex items-center">
  340. <span className="w-full border-t border-border" />
  341. </div>
  342. <div className="relative flex justify-center text-[11px] uppercase tracking-wide text-muted-foreground">
  343. <span className="bg-background px-2">or continue with</span>
  344. </div>
  345. </div>
  346. {/* 2 — Konnos */}
  347. <div className="space-y-1.5">
  348. <div className="flex justify-end px-0.5">
  349. <LastUsedBadge show={lastUsed === "konnos"} />
  350. </div>
  351. <Button
  352. type="button"
  353. variant="outline"
  354. className={
  355. lastUsed === "konnos"
  356. ? "inline-flex h-12 w-full items-center justify-center gap-2.5 ring-1 ring-accent/30"
  357. : "inline-flex h-12 w-full items-center justify-center gap-2.5"
  358. }
  359. disabled={busy}
  360. onClick={startKonnos}
  361. >
  362. {/* eslint-disable-next-line @next/next/no-img-element */}
  363. <img
  364. src="/konnos.svg"
  365. alt=""
  366. width={22}
  367. height={22}
  368. className="h-[22px] w-[22px] shrink-0 object-contain"
  369. aria-hidden
  370. />
  371. Konnos
  372. </Button>
  373. </div>
  374. {/* 3 — MetaMask */}
  375. <div className="space-y-1.5">
  376. <div className="flex justify-end px-0.5">
  377. <LastUsedBadge show={lastUsed === "metamask"} />
  378. </div>
  379. <Button
  380. type="button"
  381. variant="outline"
  382. className={
  383. lastUsed === "metamask"
  384. ? "inline-flex h-12 w-full items-center justify-center gap-2.5 ring-1 ring-accent/30"
  385. : "inline-flex h-12 w-full items-center justify-center gap-2.5"
  386. }
  387. disabled={busy || isPending}
  388. onClick={startMetaMask}
  389. >
  390. <MetaMaskLogo size={22} />
  391. {isPending ? "Connecting MetaMask…" : "MetaMask"}
  392. </Button>
  393. </div>
  394. {/* 4 — mavi wallet */}
  395. <div
  396. className={
  397. lastUsed === "mavi"
  398. ? "rounded-xl border border-accent/50 bg-card/50 p-4 ring-1 ring-accent/20"
  399. : "rounded-xl border border-border bg-card/50 p-4"
  400. }
  401. >
  402. <div className="flex justify-end px-0.5">
  403. <LastUsedBadge show={lastUsed === "mavi"} />
  404. </div>
  405. <Button
  406. type="button"
  407. variant="outline"
  408. className="inline-flex h-12 w-full items-center justify-center gap-2.5"
  409. disabled={busy}
  410. onClick={startMavi}
  411. >
  412. <MaviWalletLogo size={22} />
  413. mavi wallet
  414. </Button>
  415. {maviOpen ? (
  416. <div className="mt-4 space-y-3 border-t border-border pt-4">
  417. {maviHasVault ? (
  418. <>
  419. <p className="text-xs text-muted-foreground leading-relaxed">
  420. Unlock the mavi vault saved in this browser with your
  421. password.
  422. </p>
  423. <PasswordInput
  424. id="signin-mavi-password"
  425. label="mavi password"
  426. hideLabel
  427. autoComplete="current-password"
  428. placeholder="mavi password"
  429. value={maviPassword}
  430. onChange={setMaviPassword}
  431. disabled={busy}
  432. onEnter={() => void unlockMaviAndEnter()}
  433. />
  434. <Button
  435. type="button"
  436. className="w-full"
  437. disabled={busy || maviPassword.length < 1}
  438. onClick={() => void unlockMaviAndEnter()}
  439. >
  440. Unlock and sign in
  441. </Button>
  442. </>
  443. ) : (
  444. <div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
  445. <p>No mavi wallet on this browser yet.</p>
  446. <div className="flex flex-col gap-2">
  447. <Link
  448. href="/portal/mavi/create"
  449. className="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border px-3 font-medium text-foreground hover:bg-muted"
  450. >
  451. <MaviWalletLogo size={16} />
  452. Create mavi wallet
  453. </Link>
  454. <Link
  455. href="/portal/mavi/import"
  456. className="inline-flex h-10 items-center justify-center rounded-md px-3 text-accent-hover hover:underline"
  457. >
  458. Import recovery phrase
  459. </Link>
  460. </div>
  461. </div>
  462. )}
  463. </div>
  464. ) : null}
  465. </div>
  466. {isConnected && address ? (
  467. <div className="rounded-lg border border-border bg-muted/30 px-3 py-2 text-center">
  468. <p className="font-mono text-xs break-all text-muted-foreground">
  469. {address}
  470. </p>
  471. <Button
  472. type="button"
  473. variant="ghost"
  474. size="sm"
  475. className="mt-1"
  476. onClick={() => {
  477. disconnect();
  478. clearDemo();
  479. }}
  480. >
  481. Disconnect wallet
  482. </Button>
  483. </div>
  484. ) : null}
  485. {message ? (
  486. <p className="text-center text-sm text-proof" role="status">
  487. {message}
  488. </p>
  489. ) : null}
  490. {error || walletError ? (
  491. <ErrorBanner title="Could not sign in">
  492. {error ||
  493. (walletError?.message?.includes("provider")
  494. ? "MetaMask was not found. Install the extension, then try again."
  495. : walletError?.message)}
  496. </ErrorBanner>
  497. ) : null}
  498. </div>
  499. );
  500. }