g1t/apps/web/app/routes/device.tsx

125 lines4,570 bytesCodeBlame
1import { CircleCheck, CircleX, KeyRound } from "lucide-react";
2import { Form } from "react-router";
3
4import type { Route } from "./+types/device";
5import { page } from "../lib/meta";
6import { Button, ErrorText, Field, Input } from "../components/ui";
7import { identity } from "../lib/services.server";
8import { assertSameOrigin, requireUser } from "../lib/session.server";
9
10export function meta(args: Route.MetaArgs) {
11 return page(args, { title: "Connect an application · g1t" });
12}
13
14/** Where a tool sends a person to approve its sign-in. */
15export async function loader({ request, context }: Route.LoaderArgs) {
16 const user = requireUser(context, request);
17 const code = new URL(request.url).searchParams.get("code") ?? "";
18 return {
19 user,
20 code,
21 pending: code ? await identity.deviceLookup(code) : null,
22 };
23}
24
25export async function action({ request, context }: Route.ActionArgs) {
26 assertSameOrigin(request);
27 const user = requireUser(context, request);
28 const form = await request.formData();
29 const approve = form.get("decision") === "approve";
30 const result = await identity.deviceResolve(
31 String(form.get("code") ?? ""),
32 user,
33 approve,
34 );
35 return result.ok
36 ? { done: approve ? ("approved" as const) : ("denied" as const) }
37 : { error: result.error.message };
38}
39
40export default function Device({ loaderData, actionData }: Route.ComponentProps) {
41 const { user, code, pending } = loaderData;
42
43 if (actionData && "done" in actionData) {
44 const approved = actionData.done === "approved";
45 const Icon = approved ? CircleCheck : CircleX;
46 return (
47 <main className="mx-auto max-w-md px-4 py-32 text-center">
48 <Icon size={40} className={`mx-auto ${approved ? "text-accent" : "text-muted"}`} />
49 <h1 className="mt-6 text-2xl font-semibold tracking-tight">
50 {approved ? "Connected" : "Request denied"}
51 </h1>
52 <p className="mt-2 text-muted">
53 {approved
54 ? "Go back to the application. It will finish signing in on its own."
55 : "Nothing was given access to your account."}
56 </p>
57 </main>
58 );
59 }
60
61 return (
62 <main className="mx-auto max-w-md px-4 py-24">
63 <KeyRound size={36} className="text-accent" />
64 <h1 className="mt-6 text-2xl font-semibold tracking-tight">
65 Connect an application
66 </h1>
67
68 {pending ? (
69 <>
70 <p className="mt-2 text-muted">
71 <span className="font-medium text-fg">{pending.clientName}</span>{" "}
72 wants to act as{" "}
73 <span className="font-mono font-medium text-fg">{user.username}</span>{" "}
74 on g1t.
75 </p>
76 <p className="mt-6 text-sm text-muted">
77 Check that this code matches the one it is showing you:
78 </p>
79 <p className="mt-2 rounded-xl border border-line bg-surface py-5 text-center font-mono text-3xl font-semibold tracking-[0.2em]">
80 {pending.userCode}
81 </p>
82 <p className="mt-4 text-sm text-muted">
83 Approving creates an access token with the full rights of your
84 account. You can delete it in settings at any time.
85 </p>
86 <Form method="post" className="mt-6 flex gap-3">
87 <input type="hidden" name="code" value={pending.userCode} />
88 <div className="grow *:w-full">
89 <Button variant="accent" type="submit" name="decision" value="approve">
90 Approve
91 </Button>
92 </div>
93 <Button variant="quiet" type="submit" name="decision" value="deny">
94 Deny
95 </Button>
96 </Form>
97 </>
98 ) : (
99 <>
100 <p className="mt-2 text-muted">
101 {code
102 ? "That code is not valid or has expired. Start again from the application, or enter the code it shows."
103 : "Enter the code the application is showing you."}
104 </p>
105 <Form method="get" className="mt-6 space-y-4">
106 <Field label="Code">
107 <Input
108 name="code"
109 placeholder="XXXX-XXXX"
110 autoComplete="off"
111 autoCapitalize="characters"
112 required
113 autoFocus
114 />
115 </Field>
116 <div className="*:w-full">
117 <Button type="submit">Continue</Button>
118 </div>
119 </Form>
120 </>
121 )}
122 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
123 </main>
124 );
125}