personal web client for Bluesky
typescript solidjs bluesky atcute
at trunk 4.7 kB view raw
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};