a tool for shared writing and social publishing
1"use client";
2import {
3 confirmEmailAuthToken,
4 requestAuthEmailToken,
5} from "actions/emailAuth";
6import { loginWithEmailToken } from "actions/login";
7import { ActionAfterSignIn } from "app/api/oauth/[route]/afterSignInActions";
8import { getHomeDocs } from "app/(home-pages)/home/storage";
9import { ButtonPrimary } from "components/Buttons";
10import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
11import { BlueskySmall } from "components/Icons/BlueskySmall";
12import { Input } from "components/Input";
13import { useSmoker, useToaster } from "components/Toast";
14import React, { useState } from "react";
15import { mutate } from "swr";
16import { BlueskyTiny } from "components/Icons/BlueskyTiny";
17
18export default function LoginForm(props: {
19 noEmail?: boolean;
20 redirectRoute?: string;
21 action?: ActionAfterSignIn;
22 text: React.ReactNode;
23}) {
24 type FormState =
25 | {
26 stage: "email";
27 email: string;
28 }
29 | {
30 stage: "code";
31 email: string;
32 tokenId: string;
33 confirmationCode: string;
34 };
35
36 const [formState, setFormState] = useState<FormState>({
37 stage: "email",
38 email: "",
39 });
40
41 const handleSubmitEmail = async (e: React.FormEvent) => {
42 e.preventDefault();
43 const tokenId = await requestAuthEmailToken(formState.email);
44 setFormState({
45 stage: "code",
46 email: formState.email,
47 tokenId,
48 confirmationCode: "",
49 });
50 };
51
52 let smoker = useSmoker();
53 let toaster = useToaster();
54
55 const handleSubmitCode = async (e: React.FormEvent) => {
56 e.preventDefault();
57 let rect = e.currentTarget.getBoundingClientRect();
58
59 if (formState.stage !== "code") return;
60 const confirmedToken = await confirmEmailAuthToken(
61 formState.tokenId,
62 formState.confirmationCode,
63 );
64
65 if (!confirmedToken) {
66 smoker({
67 error: true,
68 text: "incorrect code!",
69 position: {
70 y: rect.bottom - 16,
71 x: rect.right - 220,
72 },
73 });
74 } else {
75 let localLeaflets = getHomeDocs();
76
77 await loginWithEmailToken(localLeaflets.filter((l) => !l.hidden));
78 mutate("identity");
79 toaster({
80 content: <div className="font-bold">Logged in! Welcome!</div>,
81 type: "success",
82 });
83 }
84 };
85
86 if (formState.stage === "code") {
87 return (
88 <div className="w-full max-w-md flex flex-col gap-3 py-1">
89 <div className=" text-secondary font-bold">
90 Please enter the code we sent to
91 <div className="italic truncate">{formState.email}</div>
92 </div>
93 <form onSubmit={handleSubmitCode} className="flex flex-col gap-2 ">
94 <Input
95 type="text"
96 className="input-with-border"
97 placeholder="000000"
98 value={formState.confirmationCode}
99 onChange={(e) =>
100 setFormState({
101 ...formState,
102 confirmationCode: e.target.value,
103 })
104 }
105 required
106 />
107
108 <ButtonPrimary
109 type="submit"
110 className="place-self-end"
111 disabled={formState.confirmationCode === ""}
112 onMouseDown={(e) => {}}
113 >
114 Confirm
115 </ButtonPrimary>
116 </form>
117 </div>
118 );
119 }
120
121 return (
122 <div className="flex flex-col gap-3 w-full max-w-xs pb-1">
123 <div className="flex flex-col">
124 <h4 className="text-primary">Log In or Sign Up</h4>
125 <div className=" text-tertiary text-sm">{props.text}</div>
126 </div>
127
128 <BlueskyLogin {...props} />
129
130 {props.noEmail ? null : (
131 <>
132 <div className="flex gap-2 text-border italic w-full items-center">
133 <hr className="border-border-light w-full" />
134 <div>or</div>
135 <hr className="border-border-light w-full" />
136 </div>
137 <form
138 onSubmit={handleSubmitEmail}
139 className="flex flex-col gap-2 relative"
140 >
141 <Input
142 type="email"
143 placeholder="email@example.com"
144 value={formState.email}
145 className="input-with-border p-7"
146 onChange={(e) =>
147 setFormState({
148 ...formState,
149 email: e.target.value,
150 })
151 }
152 required
153 />
154
155 <ButtonPrimary
156 type="submit"
157 className="place-self-end px-[2px]! absolute right-1 bottom-1"
158 >
159 <ArrowRightTiny />{" "}
160 </ButtonPrimary>
161 </form>
162 </>
163 )}
164 </div>
165 );
166}
167
168export function BlueskyLogin(props: {
169 redirectRoute?: string;
170 action?: ActionAfterSignIn;
171 compact?: boolean;
172}) {
173 const [signingWithHandle, setSigningWithHandle] = useState(false);
174 const [handle, setHandle] = useState("");
175
176 return (
177 <form action={`/api/oauth/login`} method="GET">
178 <input
179 type="hidden"
180 name="redirect_url"
181 value={props.redirectRoute || "/"}
182 />
183 {props.action && (
184 <input
185 type="hidden"
186 name="action"
187 value={JSON.stringify(props.action)}
188 />
189 )}
190 {signingWithHandle ? (
191 <div className="w-full flex gap-1">
192 <Input
193 type="text"
194 name="handle"
195 id="handle"
196 placeholder="you.bsky.social"
197 value={handle}
198 className="input-with-border"
199 onChange={(e) => setHandle(e.target.value)}
200 required
201 />
202 <ButtonPrimary type="submit">Sign In</ButtonPrimary>
203 </div>
204 ) : (
205 <div className="flex flex-col justify-center">
206 <ButtonPrimary
207 fullWidth={!props.compact}
208 compact={props.compact}
209 className={`${props.compact ? "mx-auto text-sm" : "py-2"}`}
210 >
211 {props.compact ? <BlueskyTiny /> : <BlueskySmall />}
212 {props.compact ? "Link" : "Log In/Sign Up with"} Bluesky
213 </ButtonPrimary>
214 <button
215 type="button"
216 className={`${props.compact ? "text-xs mt-0.5" : "text-sm mt-[6px]"} text-accent-contrast place-self-center`}
217 onClick={() => setSigningWithHandle(true)}
218 >
219 use an ATProto handle
220 </button>
221 </div>
222 )}
223 </form>
224 );
225}