| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526 |
- "use client";
- /**
- * Unified login methods — same weight for each path into the dashboard:
- * 1. Email OTP (Briven)
- * 2. Konnos OAuth (Briven)
- * 3. MetaMask (browser wallet)
- * 4. mavi wallet (local encrypted vault — create at /portal/mavi)
- */
- import { injected } from "@wagmi/connectors/injected";
- import Link from "next/link";
- import { useRouter } from "next/navigation";
- import { useCallback, useEffect, useRef, useState } from "react";
- import { useAccount, useConnect, useDisconnect } from "wagmi";
- import { usePortalAuth } from "@/components/auth/portal-auth-provider";
- import { Button } from "@/components/ui/button";
- import { ErrorBanner } from "@/components/ui/error-banner";
- import { Input } from "@/components/ui/input";
- import { PasswordInput } from "@/components/ui/password-input";
- import {
- MetaMaskLogo,
- MaviWalletLogo,
- } from "@/components/wallet/wallet-logos";
- import { getBrivenAuth } from "@/lib/auth";
- import { createDemoWalletSession } from "@/lib/demo-session";
- import {
- type LoginMethodId,
- readLastLoginMethod,
- writeLastLoginMethod,
- } from "@/lib/last-login";
- import { hasVault, unlockVault } from "@/lib/mavi/storage";
- import { establishPortalSession } from "@/lib/portal-session";
- const OTP_LEN = 6;
- function LastUsedBadge({ show }: { show: boolean }) {
- if (!show) return null;
- return (
- <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">
- Last used to sign in
- </span>
- );
- }
- export function SignInMethods({
- redirectTo = "/portal/dashboard",
- }: {
- redirectTo?: string;
- }) {
- const router = useRouter();
- const auth = getBrivenAuth();
- const { setDemoSession, clearDemo } = usePortalAuth();
- const { address, isConnected } = useAccount();
- const { connect, isPending, error: walletError } = useConnect();
- const { disconnect } = useDisconnect();
- const [email, setEmail] = useState("");
- const [otp, setOtp] = useState("");
- const [otpOpen, setOtpOpen] = useState(false);
- const [otpSent, setOtpSent] = useState(false);
- const [busy, setBusy] = useState(false);
- const [message, setMessage] = useState<string | null>(null);
- const [error, setError] = useState<string | null>(null);
- const [maviOpen, setMaviOpen] = useState(false);
- const [maviPassword, setMaviPassword] = useState("");
- const [maviHasVault, setMaviHasVault] = useState(false);
- const [lastUsed, setLastUsed] = useState<LoginMethodId | null>(null);
- const verifyingRef = useRef(false);
- const lastTriedRef = useRef("");
- useEffect(() => {
- setLastUsed(readLastLoginMethod());
- // Open email panel if that was last used
- if (readLastLoginMethod() === "email") setOtpOpen(true);
- if (readLastLoginMethod() === "mavi") setMaviOpen(true);
- setMaviHasVault(hasVault());
- }, []);
- const remember = useCallback((method: LoginMethodId) => {
- writeLastLoginMethod(method);
- setLastUsed(method);
- }, []);
- const goIn = useCallback(
- async (opts: {
- userId: string;
- method: "email" | "wallet" | "konnos";
- loginMethodId: LoginMethodId;
- email?: string;
- walletAddress?: string;
- }) => {
- remember(opts.loginMethodId);
- if (opts.method === "wallet" && opts.walletAddress) {
- setDemoSession(createDemoWalletSession(opts.walletAddress));
- }
- await establishPortalSession({
- userId: opts.userId,
- email: opts.email,
- method: opts.method,
- walletAddress: opts.walletAddress,
- });
- window.dispatchEvent(new Event("krypco-auth-change"));
- router.push(redirectTo);
- router.refresh();
- },
- [redirectTo, remember, router, setDemoSession],
- );
- const verifyOtp = useCallback(
- async (code: string) => {
- if (!auth) return;
- const cleaned = code.replace(/\s/g, "").trim();
- if (cleaned.length < 4) return;
- if (verifyingRef.current) return;
- if (lastTriedRef.current === cleaned) return;
- verifyingRef.current = true;
- lastTriedRef.current = cleaned;
- setError(null);
- setBusy(true);
- setMessage("Checking code…");
- try {
- const loginEmail = email.trim().toLowerCase();
- const r = await auth.signIn.otpVerify({
- email: loginEmail,
- otp: cleaned,
- });
- if (!r.ok) {
- setError(r.message || r.code);
- setMessage(null);
- lastTriedRef.current = "";
- return;
- }
- const userId =
- ("userId" in r && typeof r.userId === "string" && r.userId) ||
- `user_${loginEmail}`;
- setMessage("Signed in — opening dashboard…");
- await goIn({
- userId,
- method: "email",
- loginMethodId: "email",
- email: loginEmail,
- });
- } catch (e) {
- setError(e instanceof Error ? e.message : "Could not verify code");
- setMessage(null);
- lastTriedRef.current = "";
- } finally {
- setBusy(false);
- verifyingRef.current = false;
- }
- },
- [auth, email, goIn],
- );
- async function sendOtp() {
- if (!auth) {
- setError("Briven Auth is not configured.");
- return;
- }
- setError(null);
- setMessage(null);
- setBusy(true);
- try {
- const r = await auth.signIn.otpRequest({
- email: email.trim(),
- redirectTo,
- });
- if (!r.ok) {
- setError(r.message || r.code);
- return;
- }
- setOtpSent(true);
- setOtp("");
- lastTriedRef.current = "";
- setMessage("Check your email for a one-time code.");
- } catch (e) {
- setError(e instanceof Error ? e.message : "Could not send code");
- } finally {
- setBusy(false);
- }
- }
- function onOtpChange(raw: string) {
- const digits = raw.replace(/\D/g, "").slice(0, OTP_LEN);
- setOtp(digits);
- setError(null);
- if (digits.length === OTP_LEN) void verifyOtp(digits);
- }
- function startKonnos() {
- if (!auth) {
- setError("Briven Auth is not configured for Konnos.");
- return;
- }
- setError(null);
- remember("konnos");
- const { redirectUrl } = auth.signIn.social({
- provider: "konnos",
- redirectTo:
- typeof window !== "undefined"
- ? `${window.location.origin}${redirectTo}`
- : redirectTo,
- });
- window.location.assign(redirectUrl);
- }
- function startMetaMask() {
- setError(null);
- setMaviOpen(false);
- setMessage(null);
- connect(
- { connector: injected() },
- {
- onSuccess: (data) => {
- const addr = data.accounts[0];
- if (!addr) {
- setError("No account returned from MetaMask.");
- return;
- }
- setMessage("Wallet connected — opening dashboard…");
- void goIn({
- userId: `wallet_${addr.toLowerCase()}`,
- method: "wallet",
- loginMethodId: "metamask",
- walletAddress: addr,
- });
- },
- onError: (e) => {
- const msg = e.message || "MetaMask connection failed.";
- if (/provider|injected|ethereum|meta\s*mask/i.test(msg)) {
- setError(
- "MetaMask was not found. Install the MetaMask browser extension, then try again.",
- );
- } else if (/reject|denied|user/i.test(msg)) {
- setError("Connection was cancelled in MetaMask. You can try again.");
- } else {
- setError(msg);
- }
- },
- },
- );
- }
- function startMavi() {
- setError(null);
- setMessage(null);
- setMaviHasVault(hasVault());
- setMaviOpen((o) => !o);
- }
- async function unlockMaviAndEnter() {
- setError(null);
- if (!maviPassword) {
- setError("Enter your mavi password.");
- return;
- }
- setBusy(true);
- setMessage("Unlocking mavi…");
- try {
- const u = await unlockVault(maviPassword);
- setMessage("mavi unlocked — opening dashboard…");
- await goIn({
- userId: `mavi_${u.address.toLowerCase()}`,
- method: "wallet",
- loginMethodId: "mavi",
- walletAddress: u.address,
- });
- } catch (e) {
- setMessage(null);
- setError(e instanceof Error ? e.message : "mavi unlock failed");
- } finally {
- setBusy(false);
- }
- }
- return (
- <div className="space-y-4">
- <p className="text-sm text-muted-foreground leading-relaxed">
- Pick any method below. All of them take you into the same dashboard.
- </p>
- {/* 1 — Email OTP */}
- <div
- className={
- lastUsed === "email"
- ? "rounded-xl border border-accent/50 bg-card/50 p-4 ring-1 ring-accent/20"
- : "rounded-xl border border-border bg-card/50 p-4"
- }
- >
- <button
- type="button"
- className="flex w-full flex-wrap items-center justify-between gap-2 text-left"
- onClick={() => setOtpOpen((o) => !o)}
- >
- <span className="flex flex-wrap items-center gap-2">
- <span className="text-sm font-medium">Email code (OTP)</span>
- <LastUsedBadge show={lastUsed === "email"} />
- </span>
- <span className="text-xs text-muted-foreground">
- {otpOpen ? "Hide" : "Open"}
- </span>
- </button>
- {otpOpen ? (
- <div className="mt-4 space-y-3 border-t border-border pt-4">
- <div className="space-y-2">
- <label htmlFor="signin-email" className="text-sm font-medium">
- Email
- </label>
- <Input
- id="signin-email"
- type="email"
- autoComplete="email"
- placeholder="you@company.com"
- value={email}
- onChange={(e) => setEmail(e.target.value)}
- disabled={busy}
- />
- </div>
- <Button
- type="button"
- className="w-full"
- disabled={busy || !email.includes("@")}
- onClick={() => void sendOtp()}
- >
- {otpSent ? "Resend email code" : "Send email code"}
- </Button>
- {otpSent ? (
- <div className="flex flex-col items-center pt-2 text-center">
- <label
- htmlFor="signin-otp"
- className="mb-2 text-sm font-medium"
- >
- Code from email
- </label>
- <Input
- id="signin-otp"
- inputMode="numeric"
- autoComplete="one-time-code"
- autoFocus
- placeholder="······"
- maxLength={OTP_LEN}
- value={otp}
- onChange={(e) => onOtpChange(e.target.value)}
- disabled={busy}
- className="mx-auto h-14 w-full max-w-[16rem] text-center font-mono text-2xl tracking-[0.35em]"
- />
- <p className="mt-2 text-xs text-muted-foreground">
- All {OTP_LEN} digits → auto sign-in
- </p>
- </div>
- ) : null}
- </div>
- ) : null}
- </div>
- <div className="relative py-1">
- <div className="absolute inset-0 flex items-center">
- <span className="w-full border-t border-border" />
- </div>
- <div className="relative flex justify-center text-[11px] uppercase tracking-wide text-muted-foreground">
- <span className="bg-background px-2">or continue with</span>
- </div>
- </div>
- {/* 2 — Konnos */}
- <div className="space-y-1.5">
- <div className="flex justify-end px-0.5">
- <LastUsedBadge show={lastUsed === "konnos"} />
- </div>
- <Button
- type="button"
- variant="outline"
- className={
- lastUsed === "konnos"
- ? "inline-flex h-12 w-full items-center justify-center gap-2.5 ring-1 ring-accent/30"
- : "inline-flex h-12 w-full items-center justify-center gap-2.5"
- }
- disabled={busy}
- onClick={startKonnos}
- >
- {/* eslint-disable-next-line @next/next/no-img-element */}
- <img
- src="/konnos.svg"
- alt=""
- width={22}
- height={22}
- className="h-[22px] w-[22px] shrink-0 object-contain"
- aria-hidden
- />
- Konnos
- </Button>
- </div>
- {/* 3 — MetaMask */}
- <div className="space-y-1.5">
- <div className="flex justify-end px-0.5">
- <LastUsedBadge show={lastUsed === "metamask"} />
- </div>
- <Button
- type="button"
- variant="outline"
- className={
- lastUsed === "metamask"
- ? "inline-flex h-12 w-full items-center justify-center gap-2.5 ring-1 ring-accent/30"
- : "inline-flex h-12 w-full items-center justify-center gap-2.5"
- }
- disabled={busy || isPending}
- onClick={startMetaMask}
- >
- <MetaMaskLogo size={22} />
- {isPending ? "Connecting MetaMask…" : "MetaMask"}
- </Button>
- </div>
- {/* 4 — mavi wallet */}
- <div
- className={
- lastUsed === "mavi"
- ? "rounded-xl border border-accent/50 bg-card/50 p-4 ring-1 ring-accent/20"
- : "rounded-xl border border-border bg-card/50 p-4"
- }
- >
- <div className="flex justify-end px-0.5">
- <LastUsedBadge show={lastUsed === "mavi"} />
- </div>
- <Button
- type="button"
- variant="outline"
- className="inline-flex h-12 w-full items-center justify-center gap-2.5"
- disabled={busy}
- onClick={startMavi}
- >
- <MaviWalletLogo size={22} />
- mavi wallet
- </Button>
- {maviOpen ? (
- <div className="mt-4 space-y-3 border-t border-border pt-4">
- {maviHasVault ? (
- <>
- <p className="text-xs text-muted-foreground leading-relaxed">
- Unlock the mavi vault saved in this browser with your
- password.
- </p>
- <PasswordInput
- id="signin-mavi-password"
- label="mavi password"
- hideLabel
- autoComplete="current-password"
- placeholder="mavi password"
- value={maviPassword}
- onChange={setMaviPassword}
- disabled={busy}
- onEnter={() => void unlockMaviAndEnter()}
- />
- <Button
- type="button"
- className="w-full"
- disabled={busy || maviPassword.length < 1}
- onClick={() => void unlockMaviAndEnter()}
- >
- Unlock and sign in
- </Button>
- </>
- ) : (
- <div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
- <p>No mavi wallet on this browser yet.</p>
- <div className="flex flex-col gap-2">
- <Link
- href="/portal/mavi/create"
- 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"
- >
- <MaviWalletLogo size={16} />
- Create mavi wallet
- </Link>
- <Link
- href="/portal/mavi/import"
- className="inline-flex h-10 items-center justify-center rounded-md px-3 text-accent-hover hover:underline"
- >
- Import recovery phrase
- </Link>
- </div>
- </div>
- )}
- </div>
- ) : null}
- </div>
- {isConnected && address ? (
- <div className="rounded-lg border border-border bg-muted/30 px-3 py-2 text-center">
- <p className="font-mono text-xs break-all text-muted-foreground">
- {address}
- </p>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- className="mt-1"
- onClick={() => {
- disconnect();
- clearDemo();
- }}
- >
- Disconnect wallet
- </Button>
- </div>
- ) : null}
- {message ? (
- <p className="text-center text-sm text-proof" role="status">
- {message}
- </p>
- ) : null}
- {error || walletError ? (
- <ErrorBanner title="Could not sign in">
- {error ||
- (walletError?.message?.includes("provider")
- ? "MetaMask was not found. Install the extension, then try again."
- : walletError?.message)}
- </ErrorBanner>
- ) : null}
- </div>
- );
- }
|