g1t/apps/web/app/routes/settings/applications.tsx

107 lines4,930 bytesCodeBlame
1import { identity } from "../../lib/services.server";
2import { Form, redirect, useSearchParams } from "react-router";
3
4import type { Route } from "./+types/applications";
5import { page } from "../../lib/meta";
6import { ButtonLink, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
7import { DeleteButton } from "../../components/account-settings";
8import { AccessSummary, ScopeChecklist } from "../../components/token-scopes";
9import { grantFromForm } from "../../lib/token-scopes";
10import { assertSameOrigin, requireUser } from "../../lib/session.server";
11
12export function meta(args: Route.MetaArgs) {
13 return page(args, { title: "Connected applications · Settings · g1t" });
14}
15
16export async function loader({ request, context }: Route.LoaderArgs) {
17 const user = requireUser(context, request);
18 return { applications: await identity.listOAuthGrants(user) };
19}
20
21export async function action({ request, context }: Route.ActionArgs) {
22 assertSameOrigin(request);
23 const user = requireUser(context, request);
24 const form = await request.formData();
25 const id = String(form.get("id") ?? "");
26 switch (form.get("intent")) {
27 case "sign-out-application":
28 await identity.revokeOAuthGrant(user, id);
29 return null;
30 case "update-application": {
31 const grant = grantFromForm(form);
32 if (!grant.ok) return { editing: id, error: grant.error };
33 const updated = await identity.updateOAuthGrant(user, id, grant.value);
34 if (!updated.ok) return { editing: id, error: updated.error.message };
35 throw redirect("/settings/applications");
36 }
37 }
38 return null;
39}
40
41export default function ApplicationSettings({ loaderData, actionData }: Route.ComponentProps) {
42 const { applications } = loaderData;
43 const [params] = useSearchParams();
44 const editing = actionData?.editing ?? params.get("edit");
45 return (
46 <section id="applications" className="scroll-mt-20">
47 {applications.length === 0 ? (
48 <p className="text-sm text-faint">None yet.</p>
49 ) : (
50 <ul className="divide-y divide-line rounded-md border border-line">
51 {applications.map((application) => {
52 const legacy = application.legacy && application.scopes === null;
53 const open = editing === application.id;
54 return (
55 <li key={application.id} className="px-4 py-3">
56 <div className="flex flex-wrap items-start gap-x-4 gap-y-2">
57 <div className="min-w-0 grow basis-60">
58 <p className="truncate text-sm font-medium">{application.clientName}</p>
59 <p className="text-xs text-faint">
60 Connected <TimeAgo at={application.createdAt} /> · last used{" "}
61 <TimeAgo at={application.lastUsedAt} />
62 </p>
63 <AccessSummary holder={application} />
64 {legacy && (
65 <p className="mt-1.5 text-xs text-warn">
66 Signed in before applications asked for scopes, so it can do everything you can.
67 Narrow it to what it needs.
68 </p>
69 )}
70 </div>
71 <div className="ml-auto flex shrink-0 items-center gap-2">
72 {!open && (
73 <ButtonLink variant="quiet" to={`?edit=${application.id}`} preventScrollReset>
74 Change access
75 </ButtonLink>
76 )}
77 <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" pending="Signing out…" />
78 </div>
79 </div>
80 {open && (
81 <Form method="post" className="mt-4 space-y-5 border-t border-line pt-4">
82 <input type="hidden" name="intent" value="update-application" />
83 <input type="hidden" name="id" value={application.id} />
84 <p className="text-sm text-muted">
85 {application.clientName} stays signed in. What it may do changes at once, and its
86 next refresh keeps the change.
87 </p>
88 <ScopeChecklist initial={application.scopes} />
89 {actionData?.editing === application.id && <ErrorText>{actionData.error}</ErrorText>}
90 <div className="flex gap-2">
91 <SubmitButton pending="Saving…" match={{ intent: "update-application", id: application.id }}>
92 Save access
93 </SubmitButton>
94 <ButtonLink variant="quiet" to="." preventScrollReset>
95 Cancel
96 </ButtonLink>
97 </div>
98 </Form>
99 )}
100 </li>
101 );
102 })}
103 </ul>
104 )}
105 </section>
106 );
107}