feat: initial fediverse registration/login
This commit is contained in:
		
							parent
							
								
									5a22807410
								
							
						
					
					
						commit
						c4cb08cdc1
					
				
					 16 changed files with 467 additions and 111 deletions
				
			
		
							
								
								
									
										36
									
								
								Foxnouns.Frontend/app/components/RegisterError.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								Foxnouns.Frontend/app/components/RegisterError.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,36 @@ | |||
| import { ApiError, firstErrorFor } from "~/lib/api/error"; | ||||
| import { Trans, useTranslation } from "react-i18next"; | ||||
| import { Alert } from "react-bootstrap"; | ||||
| import { Link } from "@remix-run/react"; | ||||
| import ErrorAlert from "~/components/ErrorAlert"; | ||||
| 
 | ||||
| export default function RegisterError({ error }: { error: ApiError }) { | ||||
| 	const { t } = useTranslation(); | ||||
| 
 | ||||
| 	// TODO: maybe turn these messages into their own error codes?
 | ||||
| 	const ticketMessage = firstErrorFor(error, "ticket")?.message; | ||||
| 	const usernameMessage = firstErrorFor(error, "username")?.message; | ||||
| 
 | ||||
| 	if (ticketMessage === "Invalid ticket") { | ||||
| 		return ( | ||||
| 			<Alert variant="danger"> | ||||
| 				<Alert.Heading as="h4">{t("error.heading")}</Alert.Heading> | ||||
| 				<Trans t={t} i18nKey={"log-in.callback.invalid-ticket"}> | ||||
| 					Invalid ticket (it might have been too long since you logged in), please{" "} | ||||
| 					<Link to="/auth/log-in">try again</Link>. | ||||
| 				</Trans> | ||||
| 			</Alert> | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	if (usernameMessage === "Username is already taken") { | ||||
| 		return ( | ||||
| 			<Alert variant="danger"> | ||||
| 				<Alert.Heading as="h4">{t("log-in.callback.invalid-username")}</Alert.Heading> | ||||
| 				{t("log-in.callback.username-taken")} | ||||
| 			</Alert> | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	return <ErrorAlert error={error} />; | ||||
| } | ||||
|  | @ -30,7 +30,7 @@ export async function baseRequest( | |||
| 		headers: { | ||||
| 			...params.headers, | ||||
| 			...(params.token ? { Authorization: params.token } : {}), | ||||
| 			"Content-Type": "application/json", | ||||
| 			...(params.body ? { "Content-Type": "application/json" } : {}), | ||||
| 		}, | ||||
| 	}); | ||||
| 
 | ||||
|  |  | |||
|  | @ -22,6 +22,7 @@ import ErrorAlert from "~/components/ErrorAlert"; | |||
| import i18n from "~/i18next.server"; | ||||
| import { tokenCookieName } from "~/lib/utils"; | ||||
| import { useEffect } from "react"; | ||||
| import RegisterError from "~/components/RegisterError"; | ||||
| 
 | ||||
| export const meta: MetaFunction<typeof loader> = ({ data }) => { | ||||
| 	return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }]; | ||||
|  | @ -163,34 +164,3 @@ export default function DiscordCallbackPage() { | |||
| 		</RemixForm> | ||||
| 	); | ||||
| } | ||||
| 
 | ||||
| function RegisterError({ error }: { error: ApiError }) { | ||||
| 	const { t } = useTranslation(); | ||||
| 
 | ||||
| 	// TODO: maybe turn these messages into their own error codes?
 | ||||
| 	const ticketMessage = firstErrorFor(error, "ticket")?.message; | ||||
| 	const usernameMessage = firstErrorFor(error, "username")?.message; | ||||
| 
 | ||||
| 	if (ticketMessage === "Invalid ticket") { | ||||
| 		return ( | ||||
| 			<Alert variant="danger"> | ||||
| 				<Alert.Heading as="h4">{t("error.heading")}</Alert.Heading> | ||||
| 				<Trans t={t} i18nKey={"log-in.callback.invalid-ticket"}> | ||||
| 					Invalid ticket (it might have been too long since you logged in with Discord), please{" "} | ||||
| 					<Link to="/auth/log-in">try again</Link>. | ||||
| 				</Trans> | ||||
| 			</Alert> | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	if (usernameMessage === "Username is already taken") { | ||||
| 		return ( | ||||
| 			<Alert variant="danger"> | ||||
| 				<Alert.Heading as="h4">{t("log-in.callback.invalid-username")}</Alert.Heading> | ||||
| 				{t("log-in.callback.username-taken")} | ||||
| 			</Alert> | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	return <ErrorAlert error={error} />; | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,163 @@ | |||
| import { | ||||
| 	ActionFunctionArgs, | ||||
| 	json, | ||||
| 	LoaderFunctionArgs, | ||||
| 	MetaFunction, | ||||
| 	redirect, | ||||
| } from "@remix-run/node"; | ||||
| import i18n from "~/i18next.server"; | ||||
| import { type ApiError, ErrorCode } from "~/lib/api/error"; | ||||
| import serverRequest, { writeCookie } from "~/lib/request.server"; | ||||
| import { AuthResponse, CallbackResponse } from "~/lib/api/auth"; | ||||
| import { tokenCookieName } from "~/lib/utils"; | ||||
| import { | ||||
| 	Link, | ||||
| 	ShouldRevalidateFunction, | ||||
| 	useActionData, | ||||
| 	useLoaderData, | ||||
| 	useNavigate, | ||||
| } from "@remix-run/react"; | ||||
| import { Trans, useTranslation } from "react-i18next"; | ||||
| import { useEffect } from "react"; | ||||
| import { Form as RemixForm } from "@remix-run/react/dist/components"; | ||||
| import { Button, Form } from "react-bootstrap"; | ||||
| import RegisterError from "~/components/RegisterError"; | ||||
| 
 | ||||
| export const meta: MetaFunction<typeof loader> = ({ data }) => { | ||||
| 	return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }]; | ||||
| }; | ||||
| 
 | ||||
| export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => { | ||||
| 	return !actionResult; | ||||
| }; | ||||
| 
 | ||||
| export const loader = async ({ request, params }: LoaderFunctionArgs) => { | ||||
| 	const t = await i18n.getFixedT(request); | ||||
| 	const url = new URL(request.url); | ||||
| 
 | ||||
| 	const code = url.searchParams.get("code"); | ||||
| 	if (!code) throw { status: 400, code: ErrorCode.BadRequest, message: "Missing code" } as ApiError; | ||||
| 
 | ||||
| 	const resp = await serverRequest<CallbackResponse>("POST", "/auth/fediverse/callback", { | ||||
| 		body: { code, instance: params.instance! }, | ||||
| 		isInternal: true, | ||||
| 	}); | ||||
| 
 | ||||
| 	if (resp.has_account) { | ||||
| 		return json( | ||||
| 			{ | ||||
| 				meta: { title: t("log-in.callback.title.fediverse-success") }, | ||||
| 				hasAccount: true, | ||||
| 				user: resp.user!, | ||||
| 				ticket: null, | ||||
| 				remoteUser: null, | ||||
| 			}, | ||||
| 			{ | ||||
| 				headers: { | ||||
| 					"Set-Cookie": writeCookie(tokenCookieName, resp.token!), | ||||
| 				}, | ||||
| 			}, | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	return json({ | ||||
| 		meta: { title: t("log-in.callback.title.fediverse-register") }, | ||||
| 		hasAccount: false, | ||||
| 		user: null, | ||||
| 		instance: params.instance!, | ||||
| 		ticket: resp.ticket!, | ||||
| 		remoteUser: resp.remote_username!, | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| export const action = async ({ request }: ActionFunctionArgs) => { | ||||
| 	const data = await request.formData(); | ||||
| 	const username = data.get("username") as string | null; | ||||
| 	const ticket = data.get("ticket") as string | null; | ||||
| 
 | ||||
| 	if (!username || !ticket) | ||||
| 		return json({ | ||||
| 			error: { | ||||
| 				status: 403, | ||||
| 				code: ErrorCode.BadRequest, | ||||
| 				message: "Invalid username or ticket", | ||||
| 			} as ApiError, | ||||
| 			user: null, | ||||
| 		}); | ||||
| 
 | ||||
| 	try { | ||||
| 		const resp = await serverRequest<AuthResponse>("POST", "/auth/fediverse/register", { | ||||
| 			body: { username, ticket }, | ||||
| 			isInternal: true, | ||||
| 		}); | ||||
| 
 | ||||
| 		return redirect("/auth/welcome", { | ||||
| 			headers: { | ||||
| 				"Set-Cookie": writeCookie(tokenCookieName, resp.token), | ||||
| 			}, | ||||
| 			status: 303, | ||||
| 		}); | ||||
| 	} catch (e) { | ||||
| 		JSON.stringify(e); | ||||
| 
 | ||||
| 		return json({ error: e as ApiError }); | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default function FediverseCallbackPage() { | ||||
| 	const { t } = useTranslation(); | ||||
| 	const data = useLoaderData<typeof loader>(); | ||||
| 	const actionData = useActionData<typeof action>(); | ||||
| 	const navigate = useNavigate(); | ||||
| 
 | ||||
| 	useEffect(() => { | ||||
| 		setTimeout(() => { | ||||
| 			if (data.hasAccount) { | ||||
| 				navigate(`/@${data.user!.username}`); | ||||
| 			} | ||||
| 		}, 2000); | ||||
| 		// eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
| 	}, []); | ||||
| 
 | ||||
| 	if (data.hasAccount) { | ||||
| 		const username = data.user!.username; | ||||
| 
 | ||||
| 		return ( | ||||
| 			<> | ||||
| 				<h1>{t("log-in.callback.success")}</h1> | ||||
| 				<p> | ||||
| 					<Trans | ||||
| 						t={t} | ||||
| 						i18nKey={"log-in.callback.success-link"} | ||||
| 						values={{ username: data.user!.username }} | ||||
| 					> | ||||
| 						{/* @ts-expect-error react-i18next handles interpolation here */} | ||||
| 						Welcome back, <Link to={`/@${data.user!.username}`}>@{{ username }}</Link>! | ||||
| 					</Trans> | ||||
| 					<br /> | ||||
| 					{t("log-in.callback.redirect-hint")} | ||||
| 				</p> | ||||
| 			</> | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	return ( | ||||
| 		<RemixForm method="POST"> | ||||
| 			<Form as="div"> | ||||
| 				{actionData?.error && <RegisterError error={actionData.error} />} | ||||
| 				<Form.Group className="mb-3" controlId="remote-username"> | ||||
| 					<Form.Label>{t("log-in.callback.remote-username.fediverse")}</Form.Label> | ||||
| 					<Form.Control type="text" readOnly={true} value={data.remoteUser!} /> | ||||
| 				</Form.Group> | ||||
| 				<Form.Group className="mb-3" controlId="username"> | ||||
| 					<Form.Label>{t("log-in.callback.username")}</Form.Label> | ||||
| 					<Form.Control name="username" type="text" required /> | ||||
| 				</Form.Group> | ||||
| 				<input type="hidden" name="ticket" value={data.ticket!} /> | ||||
| 				<Button variant="primary" type="submit"> | ||||
| 					{t("log-in.callback.sign-up-button")} | ||||
| 				</Button> | ||||
| 			</Form> | ||||
| 		</RemixForm> | ||||
| 	); | ||||
| } | ||||
|  | @ -126,6 +126,9 @@ export default function LoginPage() { | |||
| 								{t("log-in.3rd-party.tumblr")} | ||||
| 							</ListGroup.Item> | ||||
| 						)} | ||||
| 						<ListGroup.Item action href="/auth/log-in/fediverse"> | ||||
| 							{t("log-in.3rd-party.fediverse")} | ||||
| 						</ListGroup.Item> | ||||
| 					</ListGroup> | ||||
| 				</div> | ||||
| 				{!urls.email_enabled && <div className="col-lg-3"></div>} | ||||
|  |  | |||
|  | @ -0,0 +1,75 @@ | |||
| import { | ||||
| 	LoaderFunctionArgs, | ||||
| 	json, | ||||
| 	MetaFunction, | ||||
| 	ActionFunctionArgs, | ||||
| 	redirect, | ||||
| } from "@remix-run/node"; | ||||
| import i18n from "~/i18next.server"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import { Form as RemixForm, useActionData } from "@remix-run/react"; | ||||
| import { Button, Form } from "react-bootstrap"; | ||||
| import serverRequest from "~/lib/request.server"; | ||||
| import { ApiError, ErrorCode } from "~/lib/api/error"; | ||||
| import ErrorAlert from "~/components/ErrorAlert"; | ||||
| 
 | ||||
| export const meta: MetaFunction<typeof loader> = ({ data }) => { | ||||
| 	return [{ title: `${data?.meta.title || "Log in with a Fediverse account"} • pronouns.cc` }]; | ||||
| }; | ||||
| 
 | ||||
| export const loader = async ({ request }: LoaderFunctionArgs) => { | ||||
| 	const t = await i18n.getFixedT(request); | ||||
| 
 | ||||
| 	return json({ meta: { title: t("log-in.fediverse.choose-title") } }); | ||||
| }; | ||||
| 
 | ||||
| export const action = async ({ request }: ActionFunctionArgs) => { | ||||
| 	const body = await request.formData(); | ||||
| 	const instance = body.get("instance") as string | null; | ||||
| 	if (!instance) | ||||
| 		return json({ | ||||
| 			error: { | ||||
| 				status: 403, | ||||
| 				code: ErrorCode.BadRequest, | ||||
| 				message: "Invalid instance name", | ||||
| 			} as ApiError, | ||||
| 		}); | ||||
| 
 | ||||
| 	try { | ||||
| 		const resp = await serverRequest<{ url: string }>( | ||||
| 			"GET", | ||||
| 			`/auth/fediverse?instance=${encodeURIComponent(instance)}`, | ||||
| 			{ | ||||
| 				isInternal: true, | ||||
| 			}, | ||||
| 		); | ||||
| 
 | ||||
| 		return redirect(resp.url); | ||||
| 	} catch (e) { | ||||
| 		return json({ error: e as ApiError }); | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default function AuthFediversePage() { | ||||
| 	const { t } = useTranslation(); | ||||
| 
 | ||||
| 	const data = useActionData<typeof action>(); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<> | ||||
| 			<h2>{t("log-in.fediverse.choose-form-title")}</h2> | ||||
| 			{data?.error && <ErrorAlert error={data.error} />} | ||||
| 			<RemixForm method="POST"> | ||||
| 				<Form as="div"> | ||||
| 					<Form.Group className="mb-3" controlId="instance"> | ||||
| 						<Form.Label>{t("log-in.fediverse-instance-label")}</Form.Label> | ||||
| 						<Form.Control name="instance" type="text" /> | ||||
| 					</Form.Group> | ||||
| 					<Button variant="primary" type="submit"> | ||||
| 						{t("log-in.fediverse-log-in-button")} | ||||
| 					</Button> | ||||
| 				</Form> | ||||
| 			</RemixForm> | ||||
| 		</> | ||||
| 	); | ||||
| } | ||||
|  | @ -47,22 +47,31 @@ | |||
| 	}, | ||||
| 	"log-in": { | ||||
| 		"callback": { | ||||
| 			"invalid-ticket": "Invalid ticket (it might have been too long since you logged in), please <2>try again</2>.", | ||||
| 			"invalid-username": "Invalid username", | ||||
| 			"username-taken": "That username is already taken, please try something else.", | ||||
| 			"title": { | ||||
| 				"discord-success": "Log in with Discord", | ||||
| 				"discord-register": "Register with Discord" | ||||
| 				"discord-register": "Register with Discord", | ||||
| 				"fediverse-success": "Log in with a Fediverse account", | ||||
| 				"fediverse-register": "Register with a Fediverse account" | ||||
| 			}, | ||||
| 			"success": "Successfully logged in!", | ||||
| 			"success-link": "Welcome back, <1>@{{username}}</1>!", | ||||
| 			"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.", | ||||
| 			"remote-username": { | ||||
| 				"discord": "Your discord username" | ||||
| 				"discord": "Your Discord username", | ||||
| 				"fediverse": "Your Fediverse account" | ||||
| 			}, | ||||
| 			"username": "Username", | ||||
| 			"sign-up-button": "Sign up", | ||||
| 			"invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again</2>.", | ||||
| 			"invalid-username": "Invalid username", | ||||
| 			"username-taken": "That username is already taken, please try something else." | ||||
| 			"sign-up-button": "Sign up" | ||||
| 		}, | ||||
| 		"fediverse": { | ||||
| 			"choose-title": "Log in with a Fediverse account", | ||||
| 			"choose-form-title": "Choose a Fediverse instance" | ||||
| 		}, | ||||
| 		"fediverse-instance-label": "Your Fediverse instance", | ||||
| 		"fediverse-log-in-button": "Log in", | ||||
| 		"title": "Log in", | ||||
| 		"form-title": "Log in with email", | ||||
| 		"email": "Email address", | ||||
|  | @ -74,7 +83,8 @@ | |||
| 			"desc": "If you prefer, you can also log in with one of these services:", | ||||
| 			"discord": "Log in with Discord", | ||||
| 			"google": "Log in with Google", | ||||
| 			"tumblr": "Log in with Tumblr" | ||||
| 			"tumblr": "Log in with Tumblr", | ||||
| 			"fediverse": "Log in with the Fediverse" | ||||
| 		}, | ||||
| 		"invalid-credentials": "Invalid email address or password, please check your spelling and try again." | ||||
| 	}, | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue