feat: add BookOrbit attribution requirements

Define AGPLv3 additional terms and expose attribution, source, and license details through unobtrusive About surfaces.
This commit is contained in:
neon
2026-09-10 18:03:11 +00:00
parent 26712eb02a
commit d9ea13ba45
25 changed files with 347 additions and 30 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "BookOrbit client - self-hosted library management and reading platform",
"author": "neonsolstice",
"private": true,
"license": "AGPL-3.0",
"license": "AGPL-3.0-only",
"homepage": "https://bookorbit.app",
"repository": {
"type": "git",
+2
View File
@@ -12,6 +12,7 @@ import { useAuth } from '@/features/auth/composables/useAuth'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { resolveRouteViewKey } from '@/router/view-key'
import LegalNotices from '@/components/legal/LegalNotices.vue'
const { isOpen } = useChangePasswordDialog()
const themeStore = useThemeStore()
@@ -53,6 +54,7 @@ provide(
</router-view>
<ChangePasswordDialog v-if="isOpen" />
<WhatsNewDialog v-if="popupOpen" />
<LegalNotices />
<Toaster rich-colors position="bottom-right" :visible-toasts="5" :gap="8" />
</TooltipProvider>
</template>
+11
View File
@@ -17,6 +17,7 @@ import {
ExternalLink,
Sparkles,
Languages,
Info,
} from '@lucide/vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -57,6 +58,7 @@ import { DEFAULT_FORMAT_PRIORITY, LOCALE_LABELS, Permission, type Locale } from
import { useThemeStore } from '@/stores/theme'
import { useLocaleStore } from '@/stores/locale'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { useLegalNotices } from '@/components/legal/useLegalNotices'
const { t } = useI18n()
const router = useRouter()
@@ -69,6 +71,7 @@ const { subscribe: subscribeNotifications } = useNotifications()
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
const themeStore = useThemeStore()
const localeStore = useLocaleStore()
const { openLegalNotices } = useLegalNotices()
const currentLanguageLabel = computed(() => LOCALE_LABELS[localeStore.locale])
const documentationUrl = 'https://bookorbit.app/what-is-bookorbit'
@@ -669,6 +672,10 @@ function formatBadgeStyle(fmt: string) {
<ExternalLink :size="12" class="ml-auto text-muted-foreground" />
</a>
</DropdownMenuItem>
<DropdownMenuItem @click="openLegalNotices">
<Info :size="15" class="mr-2 text-muted-foreground" />
{{ t('components.legalNotices.about') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -742,6 +749,10 @@ function formatBadgeStyle(fmt: string) {
{{ t('components.appHeader.whatsNew') }}
<span v-if="hasUnseenWhatsNew" class="ml-auto h-1.5 w-1.5 rounded-full bg-primary" :aria-label="t('components.appHeader.new')" />
</DropdownMenuItem>
<DropdownMenuItem @click="openLegalNotices">
<Info :size="14" class="mr-2 text-muted-foreground" />
{{ t('components.legalNotices.about') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>{{ t('components.appHeader.help') }}</TooltipContent>
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import LegalNotices from './LegalNotices.vue'
import PublicLegalNotices from './PublicLegalNotices.vue'
import { useLegalNotices } from './useLegalNotices'
vi.mock('reka-ui', () => ({
DialogRoot: { props: ['open'], template: '<div v-if="open"><slot /></div>' },
DialogPortal: { template: '<div><slot /></div>' },
DialogOverlay: { template: '<div />' },
DialogContent: { template: '<section class="legal-dialog"><slot /></section>' },
DialogTitle: { template: '<h2><slot /></h2>' },
DialogDescription: { template: '<p><slot /></p>' },
}))
describe('LegalNotices', () => {
beforeEach(() => {
useLegalNotices().closeLegalNotices()
})
it('keeps the legal notice available without authentication or elevated permission', async () => {
const wrapper = mount(LegalNotices)
useLegalNotices().openLegalNotices()
await nextTick()
expect(wrapper.get('.legal-dialog').text()).toContain('Powered by BookOrbit')
expect(wrapper.get('.legal-dialog').text()).toContain('Copyright (C) 2025-2026 neon.')
expect(wrapper.get('.legal-dialog').text()).toContain('Additional copyright is held by the respective BookOrbit contributors.')
expect(wrapper.get('.legal-dialog').text()).toContain('provided without warranty')
})
it('links attribution, source, license, and additional terms to the project', async () => {
const wrapper = mount(LegalNotices)
useLegalNotices().openLegalNotices()
await nextTick()
const links = wrapper.findAll('a')
expect(links.map((link) => link.attributes('href'))).toEqual([
'https://github.com/bookorbit/bookorbit',
'https://github.com/bookorbit/bookorbit',
'https://github.com/bookorbit/bookorbit/blob/main/LICENSE',
'https://github.com/bookorbit/bookorbit/blob/main/ADDITIONAL_TERMS.md',
])
})
it('uses an inline linked attribution on public pages', async () => {
const wrapper = mount(PublicLegalNotices)
const attribution = wrapper.get('a')
expect(attribution.text()).toBe('Powered by BookOrbit')
expect(attribution.attributes('href')).toBe('https://github.com/bookorbit/bookorbit')
expect(wrapper.classes()).not.toContain('fixed')
await wrapper.get('button').trigger('click')
expect(useLegalNotices().open.value).toBe(true)
})
})
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ExternalLink, X } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { useLegalNotices } from './useLegalNotices'
const { t } = useI18n()
const { open, closeLegalNotices, handleOpenChange } = useLegalNotices()
const PROJECT_URL = 'https://github.com/bookorbit/bookorbit'
const LICENSE_URL = `${PROJECT_URL}/blob/main/LICENSE`
const ADDITIONAL_TERMS_URL = `${PROJECT_URL}/blob/main/ADDITIONAL_TERMS.md`
</script>
<template>
<DialogRoot :open="open" @update:open="handleOpenChange">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-foreground/50" />
<DialogContent
aria-modal="true"
class="fixed left-1/2 top-1/2 z-50 max-h-[calc(100svh-2rem)] w-[calc(100%-2rem)] max-w-xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div class="flex items-start justify-between gap-4">
<div>
<DialogTitle class="text-lg font-semibold text-foreground">
{{ t('components.legalNotices.title') }}
</DialogTitle>
<DialogDescription class="mt-1 text-sm text-muted-foreground">
{{ t('components.legalNotices.description') }}
</DialogDescription>
</div>
<button
type="button"
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-foreground outline-hidden hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('common.close')"
@click="closeLegalNotices"
>
<X :size="16" aria-hidden="true" />
</button>
</div>
<div class="mt-6 space-y-4 text-sm">
<a
:href="PROJECT_URL"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 font-semibold text-primary underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{{ t('components.legalNotices.poweredBy') }}
<ExternalLink :size="14" aria-hidden="true" />
</a>
<div class="space-y-1 text-foreground">
<p>{{ t('components.legalNotices.originalDevelopment') }}</p>
<p>{{ t('components.legalNotices.copyright') }}</p>
<p>{{ t('components.legalNotices.contributors') }}</p>
</div>
<p class="text-foreground">{{ t('components.legalNotices.licenseSummary') }}</p>
<div class="grid gap-2 sm:grid-cols-3">
<a
:href="PROJECT_URL"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-3 py-2 font-medium text-foreground outline-hidden hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
{{ t('components.legalNotices.sourceCode') }}
<ExternalLink :size="14" aria-hidden="true" />
</a>
<a
:href="LICENSE_URL"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-3 py-2 font-medium text-foreground outline-hidden hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
{{ t('components.legalNotices.license') }}
<ExternalLink :size="14" aria-hidden="true" />
</a>
<a
:href="ADDITIONAL_TERMS_URL"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-3 py-2 font-medium text-foreground outline-hidden hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
{{ t('components.legalNotices.additionalTerms') }}
<ExternalLink :size="14" aria-hidden="true" />
</a>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useLegalNotices } from './useLegalNotices'
const { t } = useI18n()
const { openLegalNotices } = useLegalNotices()
const PROJECT_URL = 'https://github.com/bookorbit/bookorbit'
</script>
<template>
<div class="relative z-10 flex items-center justify-center gap-1.5 text-[11px] text-foreground">
<a
:href="PROJECT_URL"
target="_blank"
rel="noopener noreferrer"
class="rounded-sm outline-hidden underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{{ t('components.legalNotices.poweredBy') }}
</a>
<span aria-hidden="true">·</span>
<button
type="button"
class="rounded-sm outline-hidden underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-ring"
@click="openLegalNotices"
>
{{ t('components.legalNotices.trigger') }}
</button>
</div>
</template>
@@ -0,0 +1,19 @@
import { ref } from 'vue'
const open = ref(false)
export function useLegalNotices() {
function openLegalNotices() {
open.value = true
}
function closeLegalNotices() {
open.value = false
}
function handleOpenChange(value: boolean) {
open.value = value
}
return { open, openLegalNotices, closeLegalNotices, handleOpenChange }
}
@@ -5,6 +5,7 @@ import { Moon, Sun, Wallpaper } from '@lucide/vue'
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import { api } from '@/lib/api'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
const { t } = useI18n()
const themeStore = useThemeStore()
@@ -75,7 +76,7 @@ async function handleSubmit() {
</script>
<template>
<div class="login-bg min-h-screen flex items-center justify-center px-4 overflow-hidden">
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
<!-- Compact theme picker -->
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
<!-- Dark / light toggle -->
@@ -238,6 +239,7 @@ async function handleSubmit() {
<RouterLink to="/login" class="text-primary hover:underline">{{ t('auth.backToSignIn') }}</RouterLink>
</p>
</div>
<PublicLegalNotices />
</div>
</template>
+3 -1
View File
@@ -10,6 +10,7 @@ import { useOidc } from './composables/useOidc'
import { useSetupStatus } from './composables/useSetupStatus'
import { useLoginOptions } from './composables/useLoginOptions'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
const { t } = useI18n()
const themeStore = useThemeStore()
@@ -125,7 +126,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
</script>
<template>
<div class="login-bg min-h-screen flex items-center justify-center px-4 overflow-hidden">
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
<!-- Compact theme picker -->
<div class="fixed top-5 right-5 z-20 flex items-center gap-1.5">
<!-- Dark / light toggle -->
@@ -334,6 +335,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
<RouterLink to="/register" class="text-primary hover:underline">{{ t('auth.login.signUp') }}</RouterLink>
</p>
</div>
<PublicLegalNotices />
</div>
</template>
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { Loader2, AlertCircle } from '@lucide/vue'
import { useAuth } from '@/features/auth/composables/useAuth'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
const { t } = useI18n()
const route = useRoute()
@@ -41,7 +42,7 @@ function goToLogin() {
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-background px-4">
<div class="min-h-screen flex flex-col items-center justify-center gap-3 bg-background px-4">
<div class="w-full max-w-sm text-center">
<div v-if="status === 'loading'" class="space-y-4">
<Loader2 :size="32" class="mx-auto text-primary animate-spin" />
@@ -64,5 +65,6 @@ function goToLogin() {
</button>
</div>
</div>
<PublicLegalNotices />
</div>
</template>
@@ -5,6 +5,7 @@ import { useRouter } from 'vue-router'
import type { OidcCallbackResponse } from '@bookorbit/types'
import { OidcErrorCode } from '@bookorbit/types'
import { setAccessToken } from '@/lib/api'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import { useAuth } from './composables/useAuth'
import { useOidc, OidcLoginError } from './composables/useOidc'
@@ -82,7 +83,7 @@ onMounted(async () => {
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-background px-4">
<div class="min-h-screen flex flex-col items-center justify-center gap-3 bg-background px-4">
<div class="w-full max-w-sm text-center">
<h1 class="text-2xl font-serif font-semibold text-foreground mb-6">Book<span class="text-primary"> Orbit</span></h1>
@@ -98,5 +99,6 @@ onMounted(async () => {
<RouterLink to="/login" class="text-sm text-primary hover:underline">{{ t('auth.oidc.tryAgain') }}</RouterLink>
</div>
</div>
<PublicLegalNotices />
</div>
</template>
+3 -1
View File
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { Moon, Sun, Wallpaper } from '@lucide/vue'
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import { RegisterError, useAuth } from './composables/useAuth'
import { checkIdentityText } from './lib/identity-text'
@@ -110,7 +111,7 @@ async function handleSubmit() {
</script>
<template>
<div class="login-bg min-h-screen flex items-center justify-center px-4 overflow-hidden">
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
<!-- Compact theme picker -->
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
<!-- Dark / light toggle -->
@@ -327,6 +328,7 @@ async function handleSubmit() {
</RouterLink>
</p>
</div>
<PublicLegalNotices />
</div>
</template>
@@ -5,6 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import { Moon, Sun, Wallpaper } from '@lucide/vue'
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
const { t } = useI18n()
const themeStore = useThemeStore()
@@ -95,7 +96,7 @@ async function handleSubmit() {
</script>
<template>
<div class="login-bg min-h-screen flex items-center justify-center px-4 overflow-hidden">
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
<!-- Compact theme picker -->
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
<!-- Dark / light toggle -->
@@ -267,6 +268,7 @@ async function handleSubmit() {
<RouterLink to="/login" class="text-primary hover:underline">{{ t('auth.backToSignIn') }}</RouterLink>
</p>
</div>
<PublicLegalNotices />
</div>
</template>
+3 -1
View File
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { Moon, Sun, Wallpaper } from '@lucide/vue'
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import { useAuth } from './composables/useAuth'
const { t } = useI18n()
@@ -85,7 +86,7 @@ async function handleSubmit() {
</script>
<template>
<div class="login-bg min-h-screen flex items-center justify-center px-4 overflow-hidden">
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
<!-- Compact theme picker -->
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
<!-- Dark / light toggle -->
@@ -305,6 +306,7 @@ async function handleSubmit() {
</button>
</form>
</div>
<PublicLegalNotices />
</div>
</template>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Keyboard, X } from '@lucide/vue'
import { Info, Keyboard, X } from '@lucide/vue'
import { useLegalNotices } from '@/components/legal/useLegalNotices'
const { t } = useI18n()
const { openLegalNotices } = useLegalNotices()
const emit = defineEmits<{
close: []
@@ -51,6 +53,11 @@ function handleBackdropClick(e: MouseEvent) {
}
}
function openAboutBookOrbit() {
emit('close')
openLegalNotices()
}
onMounted(() => document.addEventListener('keydown', onKeyDown, true))
onUnmounted(() => document.removeEventListener('keydown', onKeyDown, true))
</script>
@@ -87,6 +94,17 @@ onUnmounted(() => document.removeEventListener('keydown', onKeyDown, true))
</div>
</section>
</div>
<div class="flex justify-end border-t border-border/80 px-4 py-2.5">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-foreground outline-hidden hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
@click="openAboutBookOrbit"
>
<Info :size="13" aria-hidden="true" />
{{ t('components.legalNotices.about') }}
</button>
</div>
</div>
</div>
</template>
+14
View File
@@ -7266,6 +7266,20 @@
"librariesEmptyHint": "Create a library to start adding books.",
"smartScopesEmptyHint": "Smart scopes save a set of filters you use often.",
"collectionsEmptyHint": "Collections group books you pick by hand."
},
"legalNotices": {
"about": "About BookOrbit",
"trigger": "Legal notices",
"title": "About BookOrbit",
"description": "BookOrbit attribution, licensing, and source code.",
"poweredBy": "Powered by BookOrbit",
"originalDevelopment": "This software contains material originally developed as BookOrbit.",
"copyright": "Copyright (C) 2025-2026 neon.",
"contributors": "Additional copyright is held by the respective BookOrbit contributors.",
"licenseSummary": "Licensed under the GNU Affero General Public License, version 3 only, with additional terms. This software is provided without warranty. You may redistribute and modify it under those terms.",
"sourceCode": "Source code",
"license": "AGPLv3 license",
"additionalTerms": "Additional terms"
}
},
"customIcons": {