Use navigation guards and migrate a couple of components

environments/review-front-deve-otr6gc/deployments/13419
wvffle 2022-07-30 16:52:01 +00:00 zatwierdzone przez Georg Krause
rodzic 1d4a3468ee
commit 5ea5ad3c2a
17 zmienionych plików z 700 dodań i 735 usunięć

Wyświetl plik

@ -1,14 +1,257 @@
<script setup lang="ts">
import type { Artist, Track, Album, Tag } from '~/types'
import type { RouteRecordName, RouteLocationNamedRaw } from 'vue-router'
import jQuery from 'jquery'
import { trim } from 'lodash-es'
import { useFocus, useCurrentElement } from '@vueuse/core'
import { ref, computed, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useRouter } from 'vue-router'
import { useStore } from '~/store'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
interface Emits {
(e: 'search'): void
}
type CategoryCode = 'federation' | 'podcasts' | 'artists' | 'albums' | 'tracks' | 'tags' | 'more'
interface Category {
code: CategoryCode,
name: string,
route: RouteRecordName
getId: (obj: unknown) => string
getTitle: (obj: unknown) => string
getDescription: (obj: unknown) => string
}
type SimpleCategory = Partial<Category> & Pick<Category, 'code' | 'name'>
const isCategoryGuard = (object: Category | SimpleCategory): object is Category => typeof object.route === 'string'
interface Results {
name: string,
results: Result[]
}
interface Result {
title: string
id?: string
description?: string
routerUrl: RouteLocationNamedRaw
}
const emit = defineEmits<Emits>()
const search = ref()
const { focused } = useFocus(search)
onKeyboardShortcut(['shift', 'f'], () => (focused.value = true), true)
onKeyboardShortcut(['ctrl', 'k'], () => (focused.value = true), true)
const { $pgettext } = useGettext()
const labels = computed(() => ({
placeholder: $pgettext('Sidebar/Search/Input.Placeholder', 'Search for artists, albums, tracks…'),
searchContent: $pgettext('Sidebar/Search/Input.Label', 'Search for content'),
artist: $pgettext('*/*/*/Noun', 'Artist'),
album: $pgettext('*/*/*', 'Album'),
track: $pgettext('*/*/*/Noun', 'Track'),
tag: $pgettext('*/*/*/Noun', 'Tag')
}))
const router = useRouter()
const store = useStore()
const el = useCurrentElement()
const query = ref()
const enter = () => {
jQuery(el.value).search('cancel query')
// Cancel any API search request to backend
return router.push(`/search?q=${query.value}&type=artists`)
}
const blur = () => {
search.value.blur()
}
const categories = computed(() => [
{
code: 'federation',
name: $pgettext('*/*/*', 'Federation')
},
{
code: 'podcasts',
name: $pgettext('*/*/*', 'Podcasts')
},
{
code: 'artists',
route: 'library.artists.detail',
name: labels.value.artist,
getId: (obj: Artist) => obj.id,
getTitle: (obj: Artist) => obj.name,
getDescription: () => ''
},
{
code: 'albums',
route: 'library.albums.detail',
name: labels.value.album,
getId: (obj: Album) => obj.id,
getTitle: (obj: Album) => obj.title,
getDescription: (obj: Album) => obj.artist.name
},
{
code: 'tracks',
route: 'library.tracks.detail',
name: labels.value.track,
getId: (obj: Track) => obj.id,
getTitle: (obj: Track) => obj.title,
getDescription: (obj: Track) => obj.album?.artist.name ?? obj.artist?.name ?? ''
},
{
code: 'tags',
route: 'library.tags.detail',
name: labels.value.tag,
getId: (obj: Tag) => obj.name,
getTitle: (obj: Tag) => `#${obj.name}`,
getDescription: (obj: Tag) => ''
},
{
code: 'more',
name: ''
}
] as (Category | SimpleCategory)[])
const objectId = computed(() => {
const trimmedQuery = trim(trim(query.value), '@')
if (trimmedQuery.startsWith('http://') || trimmedQuery.startsWith('https://') || trimmedQuery.includes('@')) {
return query.value
}
return null
})
onMounted(() => {
jQuery(el.value).search({
type: 'category',
minCharacters: 3,
showNoResults: true,
error: {
// @ts-expect-error Semantic is broken
noResultsHeader: $pgettext('Sidebar/Search/Error', 'No matches found'),
noResults: $pgettext('Sidebar/Search/Error.Label', 'Sorry, there are no results for this search')
},
onSelect (result, response) {
jQuery(el.value).search('set value', query.value)
router.push(result.routerUrl)
jQuery(el.value).search('hide results')
return false
},
onSearchQuery (value) {
// query.value = value
emit('search')
},
apiSettings: {
url: store.getters['instance/absoluteUrl']('api/v1/search?query={query}'),
beforeXHR: function (xhrObject) {
if (!store.state.auth.authenticated) {
return xhrObject
}
if (store.state.auth.oauth.accessToken) {
xhrObject.setRequestHeader('Authorization', store.getters['auth/header'])
}
return xhrObject
},
onResponse: function (initialResponse) {
const id = objectId.value
const results: Partial<Record<CategoryCode, Results>> = {}
let resultsEmpty = true
for (const category of categories.value) {
results[category.code] = {
name: category.name,
results: []
}
if (category.code === 'federation' && id) {
resultsEmpty = false
results[category.code]?.results.push({
title: $pgettext('Search/*/*', 'Search on the fediverse'),
routerUrl: {
name: 'search',
query: { id }
}
})
}
if (category.code === 'podcasts' && id) {
resultsEmpty = false
results[category.code]?.results.push({
title: $pgettext('Search/*/*', 'Subscribe to podcast via RSS'),
routerUrl: {
name: 'search',
query: { id, type: 'rss' }
}
})
}
if (category.code === 'more') {
results[category.code]?.results.push({
title: $pgettext('Search/*/*', 'More results 🡒'),
routerUrl: {
name: 'search',
query: { type: 'artists', q: query.value }
}
})
}
if (isCategoryGuard(category)) {
for (const result of initialResponse[category.code]) {
resultsEmpty = false
const id = category.getId(result)
results[category.code]?.results.push({
title: category.getTitle(result),
id,
routerUrl: {
name: category.route,
params: { id }
},
description: category.getDescription(result)
})
}
}
}
return {
results: resultsEmpty
? {}
: results
}
}
}
})
})
</script>
<template>
<div class="ui fluid category search">
<slot /><div class="ui icon input">
<div
class="ui fluid category search"
@keypress.enter="enter"
>
<slot />
<div class="ui icon input">
<input
ref="search"
v-model="query"
:aria-label="labels.searchContent"
type="search"
class="prompt"
name="search"
:placeholder="labels.placeholder"
@keydown.esc="$event.target.blur()"
@keydown.esc="blur"
>
<i class="search icon" />
</div>
@ -16,252 +259,3 @@
<slot name="after" />
</div>
</template>
<script>
import jQuery from 'jquery'
import router from '~/router'
import { trim } from 'lodash-es'
import { useFocus } from '@vueuse/core'
import { ref } from 'vue'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
export default {
setup () {
const search = ref()
const { focused } = useFocus(search)
onKeyboardShortcut(['shift', 'f'], () => (focused.value = true), true)
return {
search
}
},
computed: {
labels () {
return {
placeholder: this.$pgettext('Sidebar/Search/Input.Placeholder', 'Search for artists, albums, tracks…'),
searchContent: this.$pgettext('Sidebar/Search/Input.Label', 'Search for content')
}
}
},
mounted () {
const artistLabel = this.$pgettext('*/*/*/Noun', 'Artist')
const albumLabel = this.$pgettext('*/*/*', 'Album')
const trackLabel = this.$pgettext('*/*/*/Noun', 'Track')
const tagLabel = this.$pgettext('*/*/*/Noun', 'Tag')
const self = this
let searchQuery
jQuery(this.$el).keypress(function (e) {
if (e.which === 13) {
// Cancel any API search request to backend
jQuery(this.$el).search('cancel query')
// Go direct to the artist page
router.push(`/search?q=${searchQuery}&type=artists`)
}
})
jQuery(this.$el).search({
type: 'category',
minCharacters: 3,
showNoResults: true,
error: {
noResultsHeader: this.$pgettext('Sidebar/Search/Error', 'No matches found'),
noResults: this.$pgettext('Sidebar/Search/Error.Label', 'Sorry, there are no results for this search')
},
onSelect (result, response) {
jQuery(self.$el).search('set value', searchQuery)
router.push(result.routerUrl)
jQuery(self.$el).search('hide results')
return false
},
onSearchQuery (query) {
self.$emit('search')
searchQuery = query
},
apiSettings: {
beforeXHR: function (xhrObject) {
if (!self.$store.state.auth.authenticated) {
return xhrObject
}
if (self.$store.state.auth.oauth.accessToken) {
xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
}
return xhrObject
},
onResponse: function (initialResponse) {
const objId = self.extractObjId(searchQuery)
const results = {}
let isEmptyResults = true
const categories = [
{
code: 'federation',
name: self.$pgettext('*/*/*', 'Federation')
},
{
code: 'podcasts',
name: self.$pgettext('*/*/*', 'Podcasts')
},
{
code: 'artists',
route: 'library.artists.detail',
name: artistLabel,
getTitle (r) {
return r.name
},
getDescription (r) {
return ''
},
getId (t) {
return t.id
}
},
{
code: 'albums',
route: 'library.albums.detail',
name: albumLabel,
getTitle (r) {
return r.title
},
getDescription (r) {
return r.artist.name
},
getId (t) {
return t.id
}
},
{
code: 'tracks',
route: 'library.tracks.detail',
name: trackLabel,
getTitle (r) {
return r.title
},
getDescription (r) {
if (r.album) {
return `${r.album.artist.name} - ${r.album.title}`
} else {
return r.artist.name
}
},
getId (t) {
return t.id
}
},
{
code: 'tags',
route: 'library.tags.detail',
name: tagLabel,
getTitle (r) {
return `#${r.name}`
},
getDescription (r) {
return ''
},
getId (t) {
return t.name
}
},
{
code: 'more',
name: ''
}
]
categories.forEach(category => {
results[category.code] = {
name: category.name,
results: []
}
if (category.code === 'federation') {
if (objId) {
isEmptyResults = false
const searchMessage = self.$pgettext('Search/*/*', 'Search on the fediverse')
results.federation = {
name: self.$pgettext('*/*/*', 'Federation'),
results: [{
title: searchMessage,
routerUrl: {
name: 'search',
query: {
id: objId
}
}
}]
}
}
} else if (category.code === 'podcasts') {
if (objId) {
isEmptyResults = false
const searchMessage = self.$pgettext('Search/*/*', 'Subscribe to podcast via RSS')
results.podcasts = {
name: self.$pgettext('*/*/*', 'Podcasts'),
results: [{
title: searchMessage,
routerUrl: {
name: 'search',
query: {
id: objId,
type: 'rss'
}
}
}]
}
}
} else if (category.code === 'more') {
const searchMessage = self.$pgettext('Search/*/*', 'More results 🡒')
results.more = {
name: '',
results: [{
title: searchMessage,
routerUrl: {
name: 'search',
query: {
type: 'artists',
q: searchQuery
}
}
}]
}
} else {
initialResponse[category.code].forEach(result => {
isEmptyResults = false
const id = category.getId(result)
results[category.code].results.push({
title: category.getTitle(result),
id,
routerUrl: {
name: category.route,
params: {
id
}
},
description: category.getDescription(result)
})
})
}
})
return {
results: isEmptyResults ? {} : results
}
},
url: this.$store.getters['instance/absoluteUrl']('api/v1/search?query={query}')
}
})
},
methods: {
extractObjId (query) {
query = trim(query)
query = trim(query, '@')
if (query.indexOf(' ') > -1) {
return
}
if (query.startsWith('http://') || query.startsWith('https://')) {
return query
}
if (query.split('@').length > 1) {
return query
}
}
}
}
</script>

Wyświetl plik

@ -1,3 +1,66 @@
<script setup lang="ts">
import type { Album } from '~/types'
import axios from 'axios'
import { reactive, ref, watch } from 'vue'
import { useStore } from '~/store'
import AlbumCard from '~/components/audio/album/Card.vue'
interface Props {
filters: Record<string, string>
showCount?: boolean
search?: boolean
limit?: number
}
const props = withDefaults(defineProps<Props>(), {
showCount: false,
search: false,
limit: 12
})
const store = useStore()
const query = ref('')
const albums = reactive([] as Album[])
const count = ref(0)
const nextPage = ref()
const isLoading = ref(false)
const fetchData = async (url = 'albums/') => {
isLoading.value = true
try {
const params = {
q: query.value,
...props.filters,
page_size: props.limit
}
const response = await axios.get(url, { params })
nextPage.value = response.data.next
count.value = response.data.count
albums.push(...response.data.results)
} catch (error) {
// TODO (wvffle): Handle error
}
isLoading.value = false
}
const performSearch = () => {
albums.length = 0
fetchData()
}
watch(
() => store.state.moderation.lastUpdate,
() => fetchData(),
{ immediate: true }
)
</script>
<template>
<div class="wrapper">
<h3
@ -53,78 +116,3 @@
</template>
</div>
</template>
<script>
import axios from 'axios'
import AlbumCard from '~/components/audio/album/Card.vue'
export default {
components: {
AlbumCard
},
props: {
filters: { type: Object, required: true },
controls: { type: Boolean, default: true },
showCount: { type: Boolean, default: false },
search: { type: Boolean, default: false },
limit: { type: Number, default: 12 }
},
setup () {
const performSearch = () => {
this.albums.length = 0
this.fetchData()
}
return { performSearch }
},
data () {
return {
albums: [],
count: 0,
isLoading: false,
errors: null,
previousPage: null,
nextPage: null,
query: ''
}
},
watch: {
offset () {
this.fetchData()
},
'$store.state.moderation.lastUpdate': function () {
this.fetchData()
}
},
created () {
this.fetchData()
},
methods: {
fetchData (url) {
url = url || 'albums/'
this.isLoading = true
const self = this
const params = { q: this.query, ...this.filters }
params.page_size = this.limit
params.offset = this.offset
axios.get(url, { params }).then((response) => {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
self.albums = [...self.albums, ...response.data.results]
self.count = response.data.count
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
updateOffset (increment) {
if (increment) {
this.offset += this.limit
} else {
this.offset = Math.max(this.offset - this.limit, 0)
}
}
}
}
</script>

Wyświetl plik

@ -1,3 +1,66 @@
<script setup lang="ts">
import type { Artist } from '~/types'
import axios from 'axios'
import { reactive, ref, watch } from 'vue'
import { useStore } from '~/store'
import ArtistCard from '~/components/audio/artist/Card.vue'
interface Props {
filters: Record<string, string>
search?: boolean
header?: boolean
limit?: number
}
const props = withDefaults(defineProps<Props>(), {
search: false,
header: true,
limit: 12
})
const store = useStore()
const query = ref('')
const artists = reactive([] as Artist[])
const count = ref(0)
const nextPage = ref()
const isLoading = ref(false)
const fetchData = async (url = 'artists/') => {
isLoading.value = true
try {
const params = {
q: query.value,
...props.filters,
page_size: props.limit
}
const response = await axios.get(url, { params })
nextPage.value = response.data.next
count.value = response.data.count
artists.push(...response.data.results)
} catch (error) {
// TODO (wvffle): Handle error
}
isLoading.value = false
}
const performSearch = () => {
artists.length = 0
fetchData()
}
watch(
() => store.state.moderation.lastUpdate,
() => fetchData(),
{ immediate: true }
)
</script>
<template>
<div class="wrapper">
<h3
@ -21,13 +84,13 @@
<div class="ui loader" />
</div>
<artist-card
v-for="artist in objects"
v-for="artist in artists"
:key="artist.id"
:artist="artist"
/>
</div>
<slot
v-if="!isLoading && objects.length === 0"
v-if="!isLoading && artists.length === 0"
name="empty-state"
>
<empty-state
@ -49,78 +112,3 @@
</template>
</div>
</template>
<script>
import axios from 'axios'
import ArtistCard from '~/components/audio/artist/Card.vue'
export default {
components: {
ArtistCard
},
props: {
filters: { type: Object, required: true },
controls: { type: Boolean, default: true },
header: { type: Boolean, default: true },
search: { type: Boolean, default: false }
},
setup () {
const performSearch = () => {
this.objects.length = 0
this.fetchData()
}
return { performSearch }
},
data () {
return {
objects: [],
limit: 12,
count: 0,
isLoading: false,
errors: null,
previousPage: null,
nextPage: null,
query: ''
}
},
watch: {
offset () {
this.fetchData()
},
'$store.state.moderation.lastUpdate': function () {
this.fetchData()
}
},
created () {
this.fetchData()
},
methods: {
fetchData (url) {
url = url || 'artists/'
this.isLoading = true
const self = this
const params = { q: this.query, ...this.filters }
params.page_size = this.limit
params.offset = this.offset
axios.get(url, { params }).then((response) => {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
self.objects = [...self.objects, ...response.data.results]
self.count = response.data.count
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
updateOffset (increment) {
if (increment) {
this.offset += this.limit
} else {
this.offset = Math.max(this.offset - this.limit, 0)
}
}
}
}
</script>

Wyświetl plik

@ -1,13 +1,19 @@
<script setup lang="ts">
import type { Track, Listening } from '~/types'
import useWebSocketHandler from '~/composables/useWebSocketHandler'
// TODO (wvffle): Fix websocket update (#1534)
import { clone } from 'lodash-es'
import axios from 'axios'
import { ref, reactive, watch } from 'vue'
import { useStore } from '~/store'
import { clone } from 'lodash-es'
import useWebSocketHandler from '~/composables/useWebSocketHandler'
import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue'
import { ref, reactive, watch } from 'vue'
interface Emits {
(e: 'count', count: number): void
}
interface Props {
filters: Record<string, string>
@ -19,6 +25,7 @@ interface Props {
websocketHandlers?: string[]
}
const emit = defineEmits<Emits>()
const props = withDefaults(defineProps<Props>(), {
isActivity: true,
showCount: false,
@ -27,6 +34,8 @@ const props = withDefaults(defineProps<Props>(), {
websocketHandlers: () => []
})
const store = useStore()
const objects = reactive([] as Listening[])
const count = ref(0)
const nextPage = ref<string | null>(null)
@ -57,9 +66,12 @@ const fetchData = async (url = props.url) => {
isLoading.value = false
}
fetchData()
watch(
() => store.state.moderation.lastUpdate,
() => fetchData(),
{ immediate: true }
)
const emit = defineEmits(['count'])
watch(count, (to) => emit('count', to))
watch(() => props.websocketHandlers.includes('Listen'), (to) => {

Wyświetl plik

@ -1,3 +1,54 @@
<script setup lang="ts">
import axios from 'axios'
import { useGettext } from 'vue3-gettext'
import { computed, ref } from 'vue'
import ApplicationForm from '~/components/auth/ApplicationForm.vue'
interface Props {
id: number
}
const props = defineProps<Props>()
const { $pgettext } = useGettext()
const application = ref()
const labels = computed(() => ({
title: $pgettext('Content/Applications/Title', 'Edit application')
}))
const isLoading = ref(false)
const fetchApplication = async () => {
isLoading.value = true
try {
const response = await axios.get(`oauth/apps/${props.id}/`)
application.value = response.data
} catch (error) {
// TODO (wvffle): Handle error
}
isLoading.value = false
}
const refreshToken = async () => {
isLoading.value = true
try {
const response = await axios.post(`oauth/apps/${props.id}/refresh-token`)
application.value = response.data
} catch (error) {
// TODO (wvffle): Handle error
}
isLoading.value = false
}
fetchApplication()
</script>
<template>
<main
v-title="labels.title"
@ -74,51 +125,3 @@
</div>
</main>
</template>
<script>
import axios from 'axios'
import ApplicationForm from '~/components/auth/ApplicationForm.vue'
export default {
components: {
ApplicationForm
},
props: { id: { type: Number, required: true } },
data () {
return {
application: null,
isLoading: false
}
},
computed: {
labels () {
return {
title: this.$pgettext('Content/Applications/Title', 'Edit application')
}
}
},
created () {
this.fetchApplication()
},
methods: {
fetchApplication () {
this.isLoading = true
const self = this
axios.get(`oauth/apps/${this.id}/`).then((response) => {
self.isLoading = false
self.application = response.data
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
async refreshToken () {
self.isLoading = true
const response = await axios.post(`oauth/apps/${this.id}/refresh-token`)
this.application = response.data
self.isLoading = false
}
}
}
</script>

Wyświetl plik

@ -1,3 +1,95 @@
<script setup lang="ts">
import type { BackendError, Application } from '~/types'
import axios from 'axios'
import { ref, reactive, computed } from 'vue'
import { computedEager } from '@vueuse/core'
import { useGettext } from 'vue3-gettext'
import { uniq } from 'lodash-es'
import useScopes from '~/composables/auth/useScopes'
interface Emits {
(e: 'updated', application: Application): void
(e: 'created', application: Application): void
}
interface Props {
app?: Application | null
defaults?: Partial<Application>
}
const emit = defineEmits<Emits>()
const props = withDefaults(defineProps<Props>(), {
app: () => null,
defaults: () => ({})
})
const { $pgettext } = useGettext()
const scopes = useScopes()
.filter(scope => !['reports', 'security'].includes(scope.id))
const fields = reactive({
name: props.app?.name ?? props.defaults.name ?? '',
redirect_uris: props.app?.redirect_uris ?? props.defaults.redirect_uris ?? 'urn:ietf:wg:oauth:2.0:oob',
scopes: props.app?.scopes ?? props.defaults.scopes ?? 'read'
})
const errors = ref([] as string[])
const isLoading = ref(false)
const submit = async () => {
errors.value = []
isLoading.value = true
try {
const event = props.app !== null
? 'updated'
: 'created'
const request = props.app !== null
? () => axios.patch(`oauth/apps/${props.app?.client_id}/`, fields)
: () => axios.post('oauth/apps/', fields)
const response = await request()
emit(event, response.data as Application)
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
isLoading.value = false
}
const scopeArray = computed({
get: () => fields.scopes.split(' '),
set: (scopes: string[]) => uniq(scopes).join(' ')
})
const scopeParents = computedEager(() => [
{
id: 'read',
label: $pgettext('Content/OAuth Scopes/Label/Verb', 'Read'),
description: $pgettext('Content/OAuth Scopes/Help Text', 'Read-only access to user data'),
value: scopeArray.value.includes('read')
},
{
id: 'write',
label: $pgettext('Content/OAuth Scopes/Label/Verb', 'Write'),
description: $pgettext('Content/OAuth Scopes/Help Text', 'Write-only access to user data'),
value: scopeArray.value.includes('write')
}
])
const allScopes = computed(() => {
return scopeParents.value.map(parent => ({
...parent,
children: scopes.map(scope => {
const id = `${parent.id}:${scope.id}`
return { id, value: scopeArray.value.includes(id) }
})
}))
})
</script>
<template>
<form
class="ui form component-form"
@ -75,8 +167,8 @@
</div>
<div
v-for="(child, index) in parent.children"
:key="index"
v-for="child in parent.children"
:key="child.id"
>
<div class="ui child checkbox">
<input
@ -87,9 +179,6 @@
>
<label :for="child.id">
{{ child.id }}
<p class="help">
{{ child.description }}
</p>
</label>
</div>
</div>
@ -101,7 +190,7 @@
type="submit"
>
<translate
v-if="updating"
v-if="app !== null"
translate-context="Content/Applications/Button.Label/Verb"
>
Update application
@ -115,111 +204,3 @@
</button>
</form>
</template>
<script>
import { uniq } from 'lodash-es'
import axios from 'axios'
import useSharedLabels from '~/composables/locale/useSharedLabels'
export default {
props: {
app: { type: Object, required: false, default: () => { return null } },
defaults: { type: Object, required: false, default: () => { return {} } }
},
setup () {
const sharedLabels = useSharedLabels()
return { sharedLabels }
},
data () {
const defaults = this.defaults || {}
const app = this.app || {}
return {
isLoading: false,
errors: [],
fields: {
name: app.name || defaults.name || '',
redirect_uris: app.redirect_uris || defaults.redirect_uris || 'urn:ietf:wg:oauth:2.0:oob',
scopes: app.scopes || defaults.scopes || 'read'
},
scopes: [
{ id: 'profile', icon: 'user' },
{ id: 'libraries', icon: 'book' },
{ id: 'favorites', icon: 'heart' },
{ id: 'listenings', icon: 'music' },
{ id: 'follows', icon: 'users' },
{ id: 'playlists', icon: 'list' },
{ id: 'radios', icon: 'rss' },
{ id: 'filters', icon: 'eye slash' },
{ id: 'notifications', icon: 'bell' },
{ id: 'edits', icon: 'pencil alternate' }
]
}
},
computed: {
updating () {
return this.app
},
scopeArray: {
get () {
return this.fields.scopes.split(' ')
},
set (v) {
this.fields.scopes = uniq(v).join(' ')
}
},
allScopes () {
const self = this
const parents = [
{
id: 'read',
label: this.$pgettext('Content/OAuth Scopes/Label/Verb', 'Read'),
description: this.$pgettext('Content/OAuth Scopes/Help Text', 'Read-only access to user data'),
value: this.scopeArray.indexOf('read') > -1
},
{
id: 'write',
label: this.$pgettext('Content/OAuth Scopes/Label/Verb', 'Write'),
description: this.$pgettext('Content/OAuth Scopes/Help Text', 'Write-only access to user data'),
value: this.scopeArray.indexOf('write') > -1
}
]
parents.forEach((p) => {
p.children = self.scopes.map(s => {
const id = `${p.id}:${s.id}`
return {
id,
value: this.scopeArray.indexOf(id) > -1
}
})
})
return parents
}
},
methods: {
submit () {
this.errors = []
const self = this
self.isLoading = true
const payload = this.fields
let event, promise
if (this.updating) {
event = 'updated'
promise = axios.patch(`oauth/apps/${this.app.client_id}/`, payload)
} else {
event = 'created'
promise = axios.post('oauth/apps/', payload)
}
return promise.then(
response => {
self.isLoading = false
self.$emit(event, response.data)
},
error => {
self.isLoading = false
self.errors = error.backendErrors
}
)
}
}
}
</script>

Wyświetl plik

@ -1,3 +1,116 @@
<script setup lang="ts">
import type { BackendError, Application } from '~/types'
import axios from 'axios'
import { useGettext } from 'vue3-gettext'
import { whenever } from '@vueuse/core'
import { ref, computed } from 'vue'
import useSharedLabels from '~/composables/locale/useSharedLabels'
import useScopes from '~/composables/auth/useScopes'
import useFormData from '~/composables/useFormData'
interface Props {
clientId: string
redirectUri: string
scope: string
responseType: string
nonce: string
state: string
}
const props = defineProps<Props>()
const { $pgettext } = useGettext()
const sharedLabels = useSharedLabels()
const knownScopes = useScopes()
const supportedScopes = ['read', 'write']
for (const scope of knownScopes) {
supportedScopes.push(`read:${scope.id}`)
supportedScopes.push(`write:${scope.id}`)
}
const application = ref()
const errors = ref([] as string[])
const isLoading = ref(false)
const fetchApplication = async () => {
isLoading.value = true
try {
const response = await axios.get(`oauth/apps/${props.clientId}/`)
application.value = response.data as Application
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
isLoading.value = false
}
const code = ref()
const submit = async () => {
isLoading.value = true
try {
const data = useFormData({
redirect_uri: props.redirectUri,
scope: props.scope,
allow: 'true',
client_id: props.clientId,
response_type: props.responseType,
state: props.state,
nonce: props.nonce
})
const response = await axios.post('oauth/authorize/', data, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
}
})
if (props.redirectUri !== 'urn:ietf:wg:oauth:2.0:oob') {
window.location.href = response.data.redirect_uri
return
}
code.value = response.data.code
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
isLoading.value = false
}
const labels = computed(() => ({
title: $pgettext('Head/Authorize/Title', 'Allow application')
}))
const requestedScopes = computed(() => props.scope.split(' '))
const unknownRequestedScopes = computed(() => requestedScopes.value.filter(scope => !supportedScopes.includes(scope)))
const topicScopes = computed(() => {
const requested = requestedScopes.value
const write = requested.includes('write')
const read = requested.includes('read')
return knownScopes.map(scope => {
const { id } = scope
return {
id,
icon: scope.icon,
label: sharedLabels.scopes[id].label,
description: sharedLabels.scopes[id].description,
read: read || requested.includes(`read:${id}`),
write: write || requested.includes(`write:${id}`)
}
}).filter(scope => scope.read || scope.write)
})
whenever(() => props.clientId, fetchApplication)
</script>
<template>
<main
v-title="labels.title"
@ -138,142 +251,3 @@
</section>
</main>
</template>
<script>
import axios from 'axios'
import { checkRedirectToLogin } from '~/utils'
import useSharedLabels from '~/composables/locale/useSharedLabels'
import useFormData from '~/composables/useFormData'
export default {
props: {
clientId: { type: String, required: true },
redirectUri: { type: String, required: true },
scope: { type: String, required: true },
responseType: { type: String, required: true },
nonce: { type: String, required: true },
state: { type: String, required: true }
},
setup () {
const sharedLabels = useSharedLabels()
return { sharedLabels }
},
data () {
return {
application: null,
isLoading: false,
errors: [],
code: null,
knownScopes: [
{ id: 'profile', icon: 'user' },
{ id: 'libraries', icon: 'book' },
{ id: 'favorites', icon: 'heart' },
{ id: 'listenings', icon: 'music' },
{ id: 'follows', icon: 'users' },
{ id: 'playlists', icon: 'list' },
{ id: 'radios', icon: 'rss' },
{ id: 'filters', icon: 'eye slash' },
{ id: 'notifications', icon: 'bell' },
{ id: 'edits', icon: 'pencil alternate' },
{ id: 'security', icon: 'lock' },
{ id: 'reports', icon: 'warning sign' }
]
}
},
computed: {
labels () {
return {
title: this.$pgettext('Head/Authorize/Title', 'Allow application')
}
},
requestedScopes () {
return (this.scope || '').split(' ')
},
supportedScopes () {
const supported = ['read', 'write']
this.knownScopes.forEach(s => {
supported.push(`read:${s.id}`)
supported.push(`write:${s.id}`)
})
return supported
},
unknownRequestedScopes () {
const self = this
return this.requestedScopes.filter(s => {
return self.supportedScopes.indexOf(s) < 0
})
},
topicScopes () {
const self = this
const requested = this.requestedScopes
let write = false
let read = false
if (requested.indexOf('read') > -1) {
read = true
}
if (requested.indexOf('write') > -1) {
write = true
}
return this.knownScopes.map(s => {
const id = s.id
return {
id,
icon: s.icon,
label: self.sharedLabels.scopes[s.id].label,
description: self.sharedLabels.scopes[s.id].description,
read: read || requested.indexOf(`read:${id}`) > -1,
write: write || requested.indexOf(`write:${id}`) > -1
}
}).filter(c => {
return c.read || c.write
})
}
},
async created () {
await checkRedirectToLogin(this.$store, this.$router)
if (this.clientId) {
this.fetchApplication()
}
},
methods: {
fetchApplication () {
this.isLoading = true
const self = this
axios.get(`oauth/apps/${this.clientId}/`).then((response) => {
self.isLoading = false
self.application = response.data
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
submit () {
this.isLoading = true
const self = this
const data = useFormData({
redirect_uri: this.redirectUri,
scope: this.scope,
allow: true,
client_id: this.clientId,
response_type: this.responseType,
state: this.state,
nonce: this.nonce
})
axios.post('oauth/authorize/', data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' } }).then((response) => {
if (self.redirectUri === 'urn:ietf:wg:oauth:2.0:oob') {
self.isLoading = false
self.code = response.data.code
} else {
window.location.href = response.data.redirect_uri
}
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
}
}
}
</script>

Wyświetl plik

@ -7,7 +7,6 @@ import axios from 'axios'
import $ from 'jquery'
import RadioButton from '~/components/radios/Button.vue'
import Pagination from '~/components/vui/Pagination.vue'
import { checkRedirectToLogin } from '~/utils'
import TrackTable from '~/components/audio/track/Table.vue'
import useLogger from '~/composables/useLogger'
import useSharedLabels from '~/composables/locale/useSharedLabels'
@ -29,7 +28,6 @@ const props = withDefaults(defineProps<Props>(), {
})
const store = useStore()
await checkRedirectToLogin(store, useRouter())
// TODO (wvffle): Make sure everything is it's own type
const page = ref(+props.defaultPage)

Wyświetl plik

@ -0,0 +1,17 @@
export type ScopeId = 'profile' | 'libraries' | 'favorites' | 'listenings' | 'follows'
| 'playlists' | 'radios' | 'filters' | 'notifications' | 'edits' | 'security' | 'reports'
export default () => [
{ id: 'profile', icon: 'user' },
{ id: 'libraries', icon: 'book' },
{ id: 'favorites', icon: 'heart' },
{ id: 'listenings', icon: 'music' },
{ id: 'follows', icon: 'users' },
{ id: 'playlists', icon: 'list' },
{ id: 'radios', icon: 'rss' },
{ id: 'filters', icon: 'eye slash' },
{ id: 'notifications', icon: 'bell' },
{ id: 'edits', icon: 'pencil alternate' },
{ id: 'security', icon: 'lock' },
{ id: 'reports', icon: 'warning sign' }
] as { id: ScopeId, icon: string }[]

Wyświetl plik

@ -1,4 +1,5 @@
import type { PrivacyLevel, ImportStatus } from '~/types'
import type { ScopeId } from '~/composables/auth/useScopes'
import { gettext } from '~/init/locale'
@ -144,5 +145,5 @@ export default () => ({
label: $pgettext('*/Moderation/*/Noun', 'Reports'),
description: $pgettext('Content/OAuth Scopes/Paragraph', 'Access to moderation reports')
}
}
} as Record<ScopeId, { label: string, description: string }>
})

Wyświetl plik

@ -2,6 +2,7 @@
import type { NavigationGuardNext, RouteLocationNamedRaw, RouteLocationNormalized } from 'vue-router'
import type { Permission } from '~/store/auth'
import router from '~/router'
import store from '~/store'
export const hasPermissions = (permission: Permission) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
@ -13,9 +14,9 @@ export const hasPermissions = (permission: Permission) => (to: RouteLocationNorm
next({ name: 'library.index' })
}
export const requireLoggedIn = (fallbackLocation: RouteLocationNamedRaw) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
export const requireLoggedIn = (fallbackLocation?: RouteLocationNamedRaw) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
if (store.state.auth.authenticated) return next()
return next(fallbackLocation)
return next(fallbackLocation ?? { name: 'login', query: { next: router.currentRoute.value.fullPath } })
}
export const requireLoggedOut = (fallbackLocation: RouteLocationNamedRaw) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {

Wyświetl plik

@ -1,6 +1,6 @@
import type { RouteRecordRaw } from 'vue-router'
import { requireLoggedOut } from '../guards'
import { requireLoggedOut, requireLoggedIn } from '../guards'
export default [
{
@ -52,7 +52,8 @@ export default [
responseType: route.query.response_type,
nonce: route.query.nonce,
state: route.query.state
})
}),
beforeEnter: requireLoggedIn()
},
{
path: '/signup',

Wyświetl plik

@ -7,6 +7,7 @@ import manage from './manage'
import store from '~/store'
import auth from './auth'
import user from './user'
import { requireLoggedIn } from '../guards'
export default [
{
@ -71,7 +72,8 @@ export default [
props: route => ({
defaultOrdering: route.query.ordering,
defaultPage: route.query.page ? +route.query.page : undefined
})
}),
beforeEnter: requireLoggedIn()
},
...content,
...manage,

Wyświetl plik

@ -111,12 +111,9 @@ const store: Module<State, RootState> = {
},
getters: {
artistFilters: (state) => () => {
const f = state.filters.filter((f) => {
return f.target.type === 'artist'
})
const p = sortBy(f, [(e) => { return e.creation_date }])
p.reverse()
return p
const filters = state.filters.filter((filter) => filter.target.type === 'artist')
const sorted = sortBy(filters, [(e) => { return e.creation_date }])
return sorted.reverse()
}
},
actions: {

Wyświetl plik

@ -7,3 +7,7 @@ html {
scroll-behavior: auto;
}
}
input[type=search]::-webkit-search-cancel-button {
appearance: none;
}

Wyświetl plik

@ -472,3 +472,16 @@ export interface Notification {
id: number
is_read: boolean
}
// Tags stuff
export interface Tag {
name: string
}
// Application stuff
export interface Application {
client_id: string
name: string
redirect_uris: string
scopes: string
}

Wyświetl plik

@ -1,8 +1,6 @@
import { startCase } from 'lodash-es'
import type { Store } from 'vuex'
import type { Router } from 'vue-router'
import type { APIErrorResponse } from '~/types'
import type { RootState } from '~/store'
import { startCase } from 'lodash-es'
export function parseAPIErrors (responseData: APIErrorResponse, parentField?: string): string[] {
const errors = []
@ -40,13 +38,6 @@ export function getCookie (name: string) {
?.split('=')[1]
}
// TODO (wvffle): Use navigation guards
export async function checkRedirectToLogin (store: Store<RootState>, router: Router) {
if (!store.state.auth.authenticated) {
return router.push({ name: 'login', query: { next: router.currentRoute.value.fullPath } })
}
}
export function getDomain (url: string) {
const parser = document.createElement('a')
parser.href = url