Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use oauth for login #29

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 7 additions & 115 deletions components/user/UserSignIn.vue
Original file line number Diff line number Diff line change
@@ -1,98 +1,17 @@
<script setup lang="ts">
import Fuse from 'fuse.js'

const input = ref<HTMLInputElement | undefined>()
const knownServers = ref<string[]>([])
const autocompleteIndex = ref(0)
const autocompleteShow = ref(false)

const { busy, error, displayError, server, oauth } = useSignIn(input)

const fuse = shallowRef(new Fuse([] as string[]))

const filteredServers = computed(() => {
if (!server.value)
return []

const results = fuse.value.search(server.value, { limit: 6 }).map(result => result.item)
if (results[0] === server.value)
return []

return results
})

function isValidUrl(str: string) {
try {
// eslint-disable-next-line no-new
new URL(str)
return true
}
catch {
return false
}
}
const { busy, error, displayError, handle, oauth } = useSignIn(input)

async function handleInput() {
const input = server.value.trim()
if (input.startsWith('https://'))
server.value = input.replace('https://', '')
const input = handle.value.trim()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review note: removed quite some code here as you don't need to select a server with bsky as there is an integrated discovery system for a handle / did.


if (input.length)
displayError.value = false

if (
isValidUrl(`https://${input}`)
&& input.match(/^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?$/i)
// Do not hide the autocomplete if a result has an exact substring match on the input
&& !filteredServers.value.some(s => s.includes(input))
) {
autocompleteShow.value = false
}

else {
autocompleteShow.value = true
}
}

function toSelector(server: string) {
return server.replace(/[^\w-]/g, '-')
}
function move(delta: number) {
if (filteredServers.value.length === 0) {
autocompleteIndex.value = 0
return
}
autocompleteIndex.value = ((autocompleteIndex.value + delta) + filteredServers.value.length) % filteredServers.value.length
document.querySelector(`#${toSelector(filteredServers.value[autocompleteIndex.value])}`)?.scrollIntoView(false)
}

function onEnter(e: KeyboardEvent) {
if (autocompleteShow.value === true && filteredServers.value[autocompleteIndex.value]) {
server.value = filteredServers.value[autocompleteIndex.value]
e.preventDefault()
autocompleteShow.value = false
}
}

function escapeAutocomplete(evt: KeyboardEvent) {
if (!autocompleteShow.value)
return
autocompleteShow.value = false
evt.stopPropagation()
}

function select(index: number) {
server.value = filteredServers.value[index]
}

onMounted(async () => {
input?.value?.focus()
knownServers.value = await (globalThis.$fetch as any)('/api/list-servers')
fuse.value = new Fuse(knownServers.value, { shouldSort: true })
})

onClickOutside(input, () => {
autocompleteShow.value = false
})
</script>

Expand All @@ -105,7 +24,7 @@ onClickOutside(input, () => {
</div>
</div>
<div>
{{ $t('user.server_address_label') }}
{{ $t('user.handle_label') }}
</div>
<div :class="error ? 'animate animate-shake-x animate-delay-100' : null">
<div
Expand All @@ -116,44 +35,17 @@ onClickOutside(input, () => {
relative
:class="displayError ? 'border-red-600 dark:border-red-400' : null"
>
<span text-secondary-light me1>https://</span>

<input
ref="input"
v-model="server"
v-model="handle"
autocapitalize="off"
inputmode="url"
outline-none bg-transparent w-full max-w-50
spellcheck="false"
autocorrect="off"
autocomplete="off"
@input="handleInput"
@keydown.down="move(1)"
@keydown.up="move(-1)"
@keydown.enter="onEnter"
@keydown.esc.prevent="escapeAutocomplete"
@focus="autocompleteShow = true"
>
<div
v-if="autocompleteShow && filteredServers.length"
absolute left-6em right-0 top="100%"
bg-base rounded border="~ base"
z-10 shadow of-auto
overflow-y-auto
class="max-h-[8rem]"
>
<button
v-for="(name, idx) in filteredServers"
:id="toSelector(name)"
:key="name"
:value="name"
px-2 py1 font-mono w-full text-left
:class="autocompleteIndex === idx ? 'text-primary font-bold' : null"
@click="select(idx)"
>
{{ name }}
</button>
</div>
</div>
<div min-h-4>
<Transition css enter-active-class="animate animate-fade-in">
Expand All @@ -163,15 +55,15 @@ onClickOutside(input, () => {
</Transition>
</div>
</div>
<div text-secondary text-sm flex>
<!-- <div text-secondary text-sm flex>
<div i-ri:lightbulb-line me-1 />
<span>
<i18n-t keypath="user.tip_no_account">
<NuxtLink href="https://joinmastodon.org/servers" target="_blank" external class="text-primary" hover="underline">{{ $t('user.tip_register_account') }}</NuxtLink>
</i18n-t>
</span>
</div>
<button flex="~ row" gap-x-2 items-center btn-solid mt2 :disabled="!server || busy">
</div> -->
<button flex="~ row" gap-x-2 items-center btn-solid mt2 :disabled="!handle || busy">
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
Expand Down
75 changes: 23 additions & 52 deletions composables/sign-in.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import type { Ref } from 'vue'
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'

export function useSignIn(input?: Ref<HTMLInputElement | undefined>) {
const singleInstanceServer = useRuntimeConfig().public.singleInstance
export function useSignIn(_input?: Ref<HTMLInputElement | undefined>) {
const userSettings = useUserSettings()
const users = useUsers()
const { t } = useI18n()

const busy = ref(false)
const error = ref(false)
const server = ref('')
const handle = ref('')
const displayError = ref(false)

async function oauth() {
Expand All @@ -21,57 +19,30 @@ export function useSignIn(input?: Ref<HTMLInputElement | undefined>) {

await nextTick()

if (!singleInstanceServer && server.value)
server.value = server.value.split('/')[0]
const isLocalDev = location.hostname === 'localhost'

const client = await BrowserOAuthClient.load({
clientId: isLocalDev ? 'http://localhost' : `${location.protocol}//${location.host}/client-metadata.json`,
handleResolver: 'https://bsky.social/',
})

try {
let href: string
if (singleInstanceServer) {
href = await (globalThis.$fetch as any)(`/api/${publicServer.value}/login`, {
method: 'POST',
body: {
force_login: users.value.length > 0,
origin: location.origin,
lang: userSettings.value.language,
},
})
busy.value = false
}
else {
href = await (globalThis.$fetch as any)(`/api/${server.value || publicServer.value}/login`, {
method: 'POST',
body: {
force_login: users.value.some(u => u.server === server.value),
origin: location.origin,
lang: userSettings.value.language,
},
})
}
location.href = href
const url = await client.authorize(handle.value, {
scope: 'atproto transition:generic',
// prompt: users.value.length > 0 ? 'login' : 'none',
ui_locales: userSettings.value.language,
redirect_uri: isLocalDev ? `http://127.0.0.1:5314/oauth/callback` : `https://${location.host}/oauth/callback`,
})

location.href = url.toString()
}
catch (err) {
if (singleInstanceServer) {
console.error(err)
busy.value = false
await openErrorDialog({
title: t('common.error'),
messages: [t('error.sign_in_error')],
close: t('action.close'),
})
}
else {
displayError.value = true
error.value = true
await nextTick()
input?.value?.focus()
await nextTick()
setTimeout(() => {
busy.value = false
error.value = false
}, 512)
}
catch (e) {
console.error('error', e)
error.value = true
displayError.value = true
busy.value = false
}
}

return { busy, displayError, error, server, singleInstanceServer, oauth }
return { busy, displayError, error, handle, oauth }
}
4 changes: 2 additions & 2 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@
"account_not_found": "Account {0} not found",
"explore_list_empty": "Nothing is trending right now. Check back later!",
"file_size_cannot_exceed_n_mb": "File size cannot exceed {0}MB",
"sign_in_error": "Cannot connect to the server.",
"sign_in_error": "Cannot login using handle.",
"status_not_found": "Post not found",
"unsupported_file_format": "Unsupported file format"
},
Expand Down Expand Up @@ -744,7 +744,7 @@
},
"user": {
"add_existing": "Add an existing account",
"server_address_label": "Mastodon Server Address",
"handle_label": "Handle",
"sign_in_desc": "Sign in to follow profiles or hashtags, favorite, share and reply to posts, or interact from your account on a different server.",
"sign_in_notice_title": "Viewing {0} public data",
"sign_out_account": "Sign out {0}",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"release": "stale-dep && bumpp && tsx scripts/release.ts"
},
"dependencies": {
"@atproto/api": "^0.13.18",
"@atproto/oauth-client-browser": "^0.3.2",
"@emoji-mart/data": "^1.1.2",
"@fnando/sparkline": "^0.3.10",
"@iconify-emoji/twemoji": "^1.0.2",
Expand Down
Loading