mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-02-28 21:19:42 +00:00
Implement OIDC Back-Channel Logout 1.0 (RFC). When enabled, the IdP can POST a signed logout_token JWT to invalidate user sessions server-side. - Add BackchannelLogoutHandler: JWT verification via jose, jti replay protection with bounded cache, session destruction by sub or sid - Add oidcSessionId column to sessions table with index for fast lookups - Add backchannel logout route (POST /auth/openid/backchannel-logout) - Notify connected clients via socket to redirect to login page - Add authOpenIDBackchannelLogoutEnabled toggle in schema-driven settings UI - Migration v2.34.0 adds oidcSessionId column and index - Polish settings UI: auto-populate loading state, subfolder dropdown options, KeyValueEditor fixes, localized descriptions via descriptionKey, duplicate key detection, success/error toasts - Localize backchannel logout toast (ToastSessionEndedByProvider) - OidcAuthStrategy tests now use real class via require-cache stubbing
126 lines
5.7 KiB
Vue
126 lines
5.7 KiB
Vue
<template>
|
|
<div class="w-full">
|
|
<div v-for="group in sortedGroups" :key="group.id" class="mb-4">
|
|
<p class="text-sm font-semibold text-gray-200 uppercase tracking-wide mb-2 px-1">{{ group.label }}</p>
|
|
<p v-if="getGroupDescription(group)" class="text-sm text-gray-300 mb-2 px-1">{{ getGroupDescription(group) }}</p>
|
|
<div class="flex flex-wrap">
|
|
<template v-for="field in fieldsForGroup(group.id)">
|
|
<!-- Action button (e.g., Auto-populate) -->
|
|
<div v-if="field.type === 'action'" :key="field.key" class="w-36 mx-1 mt-[1.375rem] mb-2">
|
|
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" :disabled="isFieldDisabled(field)" :loading="loadingActions.includes(field.key)" @click.stop="$emit('action', field.key)">
|
|
<span class="material-symbols text-base">auto_fix_high</span>
|
|
<span class="whitespace-nowrap break-keep pl-1">{{ field.label }}</span>
|
|
</ui-btn>
|
|
</div>
|
|
|
|
<!-- Text input -->
|
|
<div v-else-if="field.type === 'text'" :key="field.key" class="w-full mb-2">
|
|
<ui-text-input-with-label :value="values[field.key]" :disabled="disabled || isFieldDisabled(field)" :label="field.label" @input="onFieldChange(field.key, $event)" />
|
|
<div v-if="getFieldHtmlDescription(field)" class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300">
|
|
<p v-html="getFieldHtmlDescription(field)"></p>
|
|
<pre v-if="field.samplePermissions" class="text-pre-wrap mt-2">{{ field.samplePermissions }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Password input -->
|
|
<ui-text-input-with-label v-else-if="field.type === 'password'" :key="field.key" :value="values[field.key]" :disabled="disabled || isFieldDisabled(field)" :label="field.label" type="password" class="mb-2" @input="onFieldChange(field.key, $event)" />
|
|
|
|
<!-- Boolean toggle -->
|
|
<div v-else-if="field.type === 'boolean'" :key="field.key" class="flex items-center py-4 px-1 w-full">
|
|
<ui-toggle-switch :value="!!values[field.key]" :disabled="disabled || isFieldDisabled(field)" @input="onFieldChange(field.key, $event)" />
|
|
<p class="pl-4 whitespace-nowrap">{{ field.label }}</p>
|
|
<p v-if="field.description" class="pl-4 text-sm text-gray-300">{{ resolveDescription(field.description) }}</p>
|
|
</div>
|
|
|
|
<!-- Select dropdown -->
|
|
<div v-else-if="field.type === 'select'" :key="field.key" class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
|
<div class="w-44">
|
|
<ui-dropdown :value="values[field.key]" small :items="getDropdownItems(field)" :label="field.label" :disabled="disabled || isFieldDisabled(field)" @input="onFieldChange(field.key, $event)" />
|
|
</div>
|
|
<p v-if="field.description" class="sm:pl-4 text-sm text-gray-300 mt-2 sm:mt-5">{{ field.description }}</p>
|
|
</div>
|
|
|
|
<!-- Array (multi-select) -->
|
|
<div v-else-if="field.type === 'array'" :key="field.key" class="w-full mb-2">
|
|
<ui-multi-select :value="values[field.key] || []" :items="values[field.key] || []" :label="field.label" :disabled="disabled || isFieldDisabled(field)" :menuDisabled="true" @input="onFieldChange(field.key, $event)" />
|
|
<p v-if="field.description" class="sm:pl-4 text-sm text-gray-300 mb-2">{{ field.description }}</p>
|
|
</div>
|
|
|
|
<!-- Key-value editor -->
|
|
<div v-else-if="field.type === 'keyvalue'" :key="field.key" class="w-full mb-2">
|
|
<app-key-value-editor :value="values[field.key] || {}" :value-options="field.valueOptions || []" :label="field.label" :disabled="disabled || isFieldDisabled(field)" @input="onFieldChange(field.key, $event)" />
|
|
<p v-if="field.description" class="sm:pl-4 text-sm text-gray-300 mt-1">{{ field.description }}</p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
export default {
|
|
props: {
|
|
schema: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
groups: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
values: {
|
|
type: Object,
|
|
default: () => ({})
|
|
},
|
|
schemaOverrides: {
|
|
type: Object,
|
|
default: () => ({})
|
|
},
|
|
disabled: Boolean,
|
|
loadingActions: {
|
|
type: Array,
|
|
default: () => []
|
|
}
|
|
},
|
|
computed: {
|
|
sortedGroups() {
|
|
return [...this.groups].sort((a, b) => a.order - b.order)
|
|
}
|
|
},
|
|
methods: {
|
|
fieldsForGroup(groupId) {
|
|
return this.schema.filter((f) => f.group === groupId).sort((a, b) => a.order - b.order)
|
|
},
|
|
isFieldDisabled(field) {
|
|
if (!field.dependsOn) return false
|
|
const depValue = this.values[field.dependsOn]
|
|
return !depValue
|
|
},
|
|
getDropdownItems(field) {
|
|
// Use schema overrides if available (e.g., from discover)
|
|
const override = this.schemaOverrides[field.key]
|
|
const options = override?.options || field.options || []
|
|
return options.map((opt) => ({
|
|
text: opt.label,
|
|
value: opt.value
|
|
}))
|
|
},
|
|
getGroupDescription(group) {
|
|
if (group.descriptionKey) return this.$strings[group.descriptionKey] || ''
|
|
return group.description || ''
|
|
},
|
|
getFieldHtmlDescription(field) {
|
|
if (field.descriptionKey) return this.$strings[field.descriptionKey] || ''
|
|
return field.description || ''
|
|
},
|
|
resolveDescription(desc) {
|
|
if (!desc || !desc.includes('{baseURL}')) return desc
|
|
const baseURL = window.location.origin + this.$config.routerBasePath
|
|
return desc.replace('{baseURL}', baseURL)
|
|
},
|
|
onFieldChange(key, value) {
|
|
this.$emit('update', { key, value })
|
|
}
|
|
}
|
|
}
|
|
</script>
|