personal web client for Bluesky
typescript
solidjs
bluesky
atcute
1import { For, Match, Show, Switch } from 'solid-js';
2
3import type { ComAtprotoServerListAppPasswords } from '@atcute/atproto';
4import { ok } from '@atcute/client';
5import { createMutation, createQuery } from '@mary/solid-query';
6
7import { openModal } from '~/globals/modals';
8
9import { formatAbsDateTime } from '~/lib/intl/time';
10import { useTitle } from '~/lib/navigation/router';
11import { useAgent } from '~/lib/states/agent';
12import { reconcile } from '~/lib/utils/misc';
13
14import * as Boxed from '~/components/boxed';
15import CircularProgressView from '~/components/circular-progress-view';
16import ErrorView from '~/components/error-view';
17import IconButton from '~/components/icon-button';
18import AddOutlinedIcon from '~/components/icons-central/add-outline';
19import CircleInfoOutlinedIcon from '~/components/icons-central/circle-info-outline';
20import TrashOutlinedIcon from '~/components/icons-central/trash-outline';
21import * as Page from '~/components/page';
22import * as Prompt from '~/components/prompt';
23import AddAppPasswordPrompt from '~/components/settings/app-passwords/add-app-password-prompt';
24
25const AppPasswordsSettingsPage = () => {
26 const { client } = useAgent();
27
28 const passwords = createQuery(() => {
29 return {
30 queryKey: ['app-passwords'],
31 async queryFn() {
32 const data = await ok(client.get('com.atproto.server.listAppPasswords'));
33
34 return data.passwords;
35 },
36 structuralSharing(oldData, newData): any {
37 // @ts-expect-error
38 return reconcile(oldData, newData, 'createdAt');
39 },
40 };
41 });
42
43 useTitle(() => `App passwords — ${import.meta.env.VITE_APP_NAME}`);
44
45 return (
46 <>
47 <Page.Header>
48 <Page.HeaderAccessory>
49 <Page.Back to="/" />
50 </Page.HeaderAccessory>
51
52 <Page.Heading title="App passwords" />
53
54 <Page.HeaderAccessory>
55 <IconButton
56 icon={AddOutlinedIcon}
57 title="Create new app password"
58 onClick={() => {
59 openModal(() => <AddAppPasswordPrompt />);
60 }}
61 />
62 </Page.HeaderAccessory>
63 </Page.Header>
64
65 <Boxed.Container>
66 <Boxed.Group>
67 <Boxed.GroupBlurb>
68 Use app passwords to sign in to Bluesky clients and other services without giving full access to
69 your account or password.
70 </Boxed.GroupBlurb>
71
72 <Switch>
73 <Match when={passwords.data}>
74 {(entries) => (
75 <Show
76 when={entries().length > 0}
77 fallback={
78 <p class="py-6 text-center text-base font-medium">No app passwords set up yet.</p>
79 }
80 >
81 <Boxed.List>
82 <For each={entries()}>{(item) => <PasswordEntry item={item} />}</For>
83 </Boxed.List>
84 </Show>
85 )}
86 </Match>
87
88 <Match when={passwords.error}>
89 {(err) => <ErrorView error={err()} onRetry={() => passwords.refetch()} />}
90 </Match>
91
92 <Match when>
93 <CircularProgressView />
94 </Match>
95 </Switch>
96 </Boxed.Group>
97 </Boxed.Container>
98 </>
99 );
100};
101
102export default AppPasswordsSettingsPage;
103
104interface PasswordEntryProps {
105 item: ComAtprotoServerListAppPasswords.AppPassword;
106}
107
108const PasswordEntry = ({ item }: PasswordEntryProps) => {
109 const { client } = useAgent();
110
111 const isPrivileged = item.privileged;
112
113 const mutation = createMutation((queryClient) => {
114 return {
115 async mutationFn() {
116 await ok(
117 client.post('com.atproto.server.revokeAppPassword', {
118 as: null,
119 input: { name: item.name },
120 }),
121 );
122 },
123 async onSuccess() {
124 await queryClient.invalidateQueries({ queryKey: ['app-passwords'] });
125 },
126 };
127 });
128
129 return (
130 <div class="flex justify-between gap-4 px-4 py-3 text-left">
131 <div class="flex min-w-0 flex-col">
132 <p class={`break-words text-sm font-medium` + (mutation.isPending ? ` text-contrast-muted` : ``)}>
133 {/* @once */ item.name}
134 </p>
135 <p class="min-w-0 break-words text-de text-contrast-muted">
136 {/* @once */ `Created at ${formatAbsDateTime(item.createdAt)}`}
137 </p>
138
139 {isPrivileged && (
140 <p class="mt-1 flex min-w-0 items-center gap-2 text-contrast-muted">
141 <CircleInfoOutlinedIcon class="text-sm" />
142 <span class="text-de">Privileged access</span>
143 </p>
144 )}
145 </div>
146
147 <IconButton
148 icon={TrashOutlinedIcon}
149 title="Remove this app password"
150 disabled={mutation.isPending}
151 onClick={() => {
152 openModal(() => (
153 <Prompt.Confirm
154 title="Delete this app password?"
155 description="Any clients or services using this password will be signed out"
156 danger
157 confirmLabel="Delete"
158 onConfirm={() => mutation.mutate()}
159 />
160 ));
161 }}
162 variant="danger"
163 class="-mr-2 mt-0.5"
164 />
165 </div>
166 );
167};