briven-passwordless-sign-in.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. "use client";
  2. import { useRouter } from "next/navigation";
  3. import { useCallback, useRef, useState } from "react";
  4. import { Button } from "@/components/ui/button";
  5. import { Input } from "@/components/ui/input";
  6. import { getBrivenAuth } from "@/lib/auth";
  7. import { establishPortalSession } from "@/lib/portal-session";
  8. /** Most Briven email OTPs are 6 digits — verify as soon as that many land. */
  9. const OTP_LEN = 6;
  10. /**
  11. * Email OTP + Konnos OAuth (magic link off — use Briven Auth Providers).
  12. * After OTP success we mint a first-party portal session so the dashboard sticks.
  13. */
  14. export function BrivenPasswordlessSignIn({
  15. redirectTo = "/portal/dashboard",
  16. }: {
  17. redirectTo?: string;
  18. }) {
  19. const router = useRouter();
  20. const auth = getBrivenAuth();
  21. const [email, setEmail] = useState("");
  22. const [otp, setOtp] = useState("");
  23. const [otpSent, setOtpSent] = useState(false);
  24. const [busy, setBusy] = useState(false);
  25. const [message, setMessage] = useState<string | null>(null);
  26. const [error, setError] = useState<string | null>(null);
  27. const verifyingRef = useRef(false);
  28. const lastTriedRef = useRef("");
  29. const finishLogin = useCallback(
  30. async (userId: string, loginEmail: string) => {
  31. await establishPortalSession({ userId, email: loginEmail });
  32. window.dispatchEvent(new Event("krypco-auth-change"));
  33. router.push(redirectTo);
  34. router.refresh();
  35. },
  36. [redirectTo, router],
  37. );
  38. const verifyOtp = useCallback(
  39. async (code: string) => {
  40. if (!auth) return;
  41. const cleaned = code.replace(/\s/g, "").trim();
  42. if (cleaned.length < 4) return;
  43. if (verifyingRef.current) return;
  44. if (lastTriedRef.current === cleaned) return;
  45. verifyingRef.current = true;
  46. lastTriedRef.current = cleaned;
  47. setError(null);
  48. setBusy(true);
  49. setMessage("Checking code…");
  50. try {
  51. const loginEmail = email.trim().toLowerCase();
  52. const r = await auth.signIn.otpVerify({
  53. email: loginEmail,
  54. otp: cleaned,
  55. });
  56. if (!r.ok) {
  57. setError(r.message || r.code);
  58. setMessage(null);
  59. lastTriedRef.current = "";
  60. return;
  61. }
  62. const userId =
  63. ("userId" in r && typeof r.userId === "string" && r.userId) ||
  64. `user_${loginEmail}`;
  65. setMessage("Signed in — opening dashboard…");
  66. await finishLogin(userId, loginEmail);
  67. } catch (e) {
  68. setError(e instanceof Error ? e.message : "Could not verify code");
  69. setMessage(null);
  70. lastTriedRef.current = "";
  71. } finally {
  72. setBusy(false);
  73. verifyingRef.current = false;
  74. }
  75. },
  76. [auth, email, finishLogin],
  77. );
  78. if (!auth) {
  79. return (
  80. <p className="text-sm text-muted-foreground">
  81. Briven Auth key missing in environment.
  82. </p>
  83. );
  84. }
  85. async function sendOtp() {
  86. if (!auth) return;
  87. setError(null);
  88. setMessage(null);
  89. setBusy(true);
  90. try {
  91. const r = await auth.signIn.otpRequest({
  92. email: email.trim(),
  93. redirectTo,
  94. });
  95. if (!r.ok) {
  96. setError(r.message || r.code);
  97. return;
  98. }
  99. setOtpSent(true);
  100. setOtp("");
  101. lastTriedRef.current = "";
  102. setMessage("Check your email for a one-time code — paste it below.");
  103. } catch (e) {
  104. setError(e instanceof Error ? e.message : "Could not send code");
  105. } finally {
  106. setBusy(false);
  107. }
  108. }
  109. function onOtpChange(raw: string) {
  110. const digits = raw.replace(/\D/g, "").slice(0, OTP_LEN);
  111. setOtp(digits);
  112. setError(null);
  113. if (digits.length === OTP_LEN) {
  114. void verifyOtp(digits);
  115. }
  116. }
  117. function startKonnos() {
  118. if (!auth) return;
  119. setError(null);
  120. const { redirectUrl } = auth.signIn.social({
  121. provider: "konnos",
  122. redirectTo:
  123. typeof window !== "undefined"
  124. ? `${window.location.origin}${redirectTo}`
  125. : redirectTo,
  126. });
  127. window.location.assign(redirectUrl);
  128. }
  129. return (
  130. <div className="space-y-5">
  131. <div className="space-y-2">
  132. <label htmlFor="briven-email" className="text-sm font-medium">
  133. Email
  134. </label>
  135. <Input
  136. id="briven-email"
  137. type="email"
  138. autoComplete="email"
  139. placeholder="you@company.com"
  140. value={email}
  141. onChange={(e) => setEmail(e.target.value)}
  142. disabled={busy}
  143. />
  144. </div>
  145. <Button
  146. type="button"
  147. className="w-full"
  148. disabled={busy || !email.includes("@")}
  149. onClick={() => void sendOtp()}
  150. >
  151. {otpSent ? "Resend email code" : "Send email code"}
  152. </Button>
  153. {otpSent ? (
  154. <div className="flex flex-col items-center border-t border-border pt-6 text-center">
  155. <label
  156. htmlFor="briven-otp"
  157. className="mb-3 text-sm font-medium text-foreground"
  158. >
  159. Code from email
  160. </label>
  161. <Input
  162. id="briven-otp"
  163. inputMode="numeric"
  164. autoComplete="one-time-code"
  165. autoFocus
  166. placeholder="······"
  167. maxLength={OTP_LEN}
  168. value={otp}
  169. onChange={(e) => onOtpChange(e.target.value)}
  170. disabled={busy}
  171. aria-label="Six-digit code from your email"
  172. className="mx-auto h-14 w-full max-w-[16rem] text-center font-mono text-2xl tracking-[0.35em] tabular-nums"
  173. />
  174. <p className="mt-3 max-w-xs text-xs text-muted-foreground leading-relaxed">
  175. Paste or type all {OTP_LEN} digits — we check the code and open the
  176. dashboard automatically.
  177. </p>
  178. {busy && otp.length === OTP_LEN ? (
  179. <p className="mt-2 text-sm text-muted-foreground" role="status">
  180. Signing you in…
  181. </p>
  182. ) : null}
  183. {otp.length >= 4 && otp.length < OTP_LEN ? (
  184. <Button
  185. type="button"
  186. className="mt-4"
  187. disabled={busy}
  188. onClick={() => void verifyOtp(otp)}
  189. >
  190. Verify and continue
  191. </Button>
  192. ) : null}
  193. </div>
  194. ) : null}
  195. <div className="relative py-2">
  196. <div className="absolute inset-0 flex items-center">
  197. <span className="w-full border-t border-border" />
  198. </div>
  199. <div className="relative flex justify-center text-xs uppercase tracking-wide text-muted-foreground">
  200. <span className="bg-card px-2">or</span>
  201. </div>
  202. </div>
  203. <Button
  204. type="button"
  205. variant="outline"
  206. className="inline-flex w-full items-center justify-center gap-2"
  207. disabled={busy}
  208. onClick={startKonnos}
  209. >
  210. {/* eslint-disable-next-line @next/next/no-img-element */}
  211. <img
  212. src="/konnos.svg"
  213. alt=""
  214. width={20}
  215. height={20}
  216. className="h-5 w-5 shrink-0 object-contain"
  217. aria-hidden
  218. />
  219. Continue with Konnos
  220. </Button>
  221. {message ? (
  222. <p className="text-center text-sm text-proof" role="status">
  223. {message}
  224. </p>
  225. ) : null}
  226. {error ? (
  227. <p className="text-center text-sm text-pending" role="alert">
  228. {error}
  229. </p>
  230. ) : null}
  231. <p className="text-xs text-muted-foreground leading-relaxed">
  232. Sign in with a one-time email code. Magic links are off. MFA (if
  233. enrolled) is handled by Briven after the first factor.
  234. </p>
  235. </div>
  236. );
  237. }