mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(auth): add SSO-only authentication mode
Require an enabled OIDC-linked superuser before disabling password flows. Track session authentication methods and fail closed across the API, web client, WebSockets, and administrative mutations. Closes #1002
This commit is contained in:
@@ -37,6 +37,7 @@ const props = defineProps<{
|
||||
canManageSuperuser?: boolean
|
||||
currentUserId?: number
|
||||
canDelete?: boolean
|
||||
passwordLoginEnabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -203,6 +204,14 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.user, () => props.passwordLoginEnabled],
|
||||
([user, passwordLoginEnabled]) => {
|
||||
if (!user && passwordLoginEnabled !== true) isSharedAccount.value = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function selectSection(section: UserFormSection) {
|
||||
activeSection.value = section
|
||||
}
|
||||
@@ -339,7 +348,10 @@ async function handleSubmit() {
|
||||
</ul>
|
||||
|
||||
<section v-else-if="activeSection === 'profile'" class="space-y-5">
|
||||
<label v-if="!isEdit" class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-card px-3 py-2.5">
|
||||
<label
|
||||
v-if="!isEdit && passwordLoginEnabled"
|
||||
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-card px-3 py-2.5"
|
||||
>
|
||||
<input v-model="isSharedAccount" type="checkbox" class="mt-0.5 size-4 shrink-0 rounded border-input accent-primary" />
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-foreground">{{ t('adminFeature.userForm.sharedAccount') }}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { api } from '@/lib/api'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { useLoginOptions } from '@/features/auth/composables/useLoginOptions'
|
||||
import { useUsers, type UserRow } from './composables/useUsers'
|
||||
import { useSelfRegistration } from './composables/useSelfRegistration'
|
||||
import UserFormDrawer from './UserFormDrawer.vue'
|
||||
@@ -24,6 +25,8 @@ const HEADER_ACTIONS_TARGET = '#settings-header-actions'
|
||||
const { t } = useI18n()
|
||||
const { isSuperuser, hasPermission } = usePermissions()
|
||||
const { user: currentUser } = useAuth()
|
||||
const { loginOptions, fetchLoginOptions } = useLoginOptions()
|
||||
const passwordLoginEnabled = computed(() => loginOptions.value?.passwordLoginEnabled === true)
|
||||
|
||||
const {
|
||||
users,
|
||||
@@ -119,7 +122,7 @@ const superuserConfirmLabel = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
headerSlotAvailable.value = document.getElementById(HEADER_ACTIONS_TARGET.slice(1)) !== null
|
||||
await Promise.all([reload(), loadStatic()])
|
||||
await Promise.all([reload(), loadStatic(), fetchLoginOptions().catch(() => undefined)])
|
||||
if (canManageAppSettings.value) await loadSelfRegistration()
|
||||
})
|
||||
|
||||
@@ -186,7 +189,7 @@ function canManage(user: UserRow): boolean {
|
||||
}
|
||||
|
||||
function isPasswordResettable(user: UserRow): boolean {
|
||||
return user.provisioningMethod !== 'oidc' && user.provisioningMethod !== 'shared'
|
||||
return passwordLoginEnabled.value && user.provisioningMethod !== 'oidc' && user.provisioningMethod !== 'shared'
|
||||
}
|
||||
|
||||
function isLocked(user: UserRow): boolean {
|
||||
@@ -363,7 +366,7 @@ async function saveDefaultLibraryAccess() {
|
||||
<p class="hidden text-sm text-muted-foreground lg:block">{{ pageSubtitle }}</p>
|
||||
<Button size="sm" type="button" @click="openCreate">
|
||||
<UserPlus :size="14" aria-hidden="true" />
|
||||
{{ t('adminFeature.usersPage.createUser') }}
|
||||
{{ passwordLoginEnabled ? t('adminFeature.usersPage.createUser') : t('adminFeature.userForm.createSharedAccount') }}
|
||||
</Button>
|
||||
</Teleport>
|
||||
|
||||
@@ -375,6 +378,7 @@ async function saveDefaultLibraryAccess() {
|
||||
:items="attention"
|
||||
:total="summary.attention"
|
||||
:busy-user-id="busyUserId"
|
||||
:password-login-enabled="passwordLoginEnabled"
|
||||
@unlock="handleUnlock"
|
||||
@send-reset-link="handleResetPassword"
|
||||
@open="openById"
|
||||
@@ -468,6 +472,7 @@ async function saveDefaultLibraryAccess() {
|
||||
:is-locked="isLocked"
|
||||
:is-resettable="isPasswordResettable"
|
||||
:needs-attention="needsAttention"
|
||||
:password-login-enabled="passwordLoginEnabled"
|
||||
@sort="handleSort"
|
||||
@edit="openEdit"
|
||||
@unlock="unlockUser"
|
||||
@@ -500,7 +505,7 @@ async function saveDefaultLibraryAccess() {
|
||||
|
||||
<NewAccountDefaults
|
||||
v-if="showDefaults"
|
||||
:show-self-registration="canManageAppSettings"
|
||||
:show-self-registration="canManageAppSettings && passwordLoginEnabled"
|
||||
:show-library-defaults="canManageUserDefaults"
|
||||
:allow-registration="allowRegistration"
|
||||
:saving-self-registration="savingSelfRegistration"
|
||||
@@ -523,6 +528,7 @@ async function saveDefaultLibraryAccess() {
|
||||
:can-manage-superuser="isSuperuser"
|
||||
:current-user-id="currentUser?.id"
|
||||
:can-delete="canDeleteEditingUser"
|
||||
:password-login-enabled="passwordLoginEnabled"
|
||||
@close="closeDrawer"
|
||||
@saved="onSaved"
|
||||
@request-superuser-change="requestSuperuserChange"
|
||||
|
||||
@@ -68,7 +68,7 @@ type Drawer = ReturnType<typeof mount>
|
||||
|
||||
async function mountDrawer(props: Record<string, unknown> = {}): Promise<Drawer> {
|
||||
const wrapper = mount(UserFormDrawer, {
|
||||
props: { user: USER, libraries: LIBRARIES, defaultLibraryIds: [], ...props },
|
||||
props: { user: USER, libraries: LIBRARIES, defaultLibraryIds: [], passwordLoginEnabled: true, ...props },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
|
||||
@@ -4,10 +4,11 @@ import { flushPromises, mount, shallowMount } from '@vue/test-utils'
|
||||
import UsersPage from '../UsersPage.vue'
|
||||
import UserRosterTable from '../components/UserRosterTable.vue'
|
||||
|
||||
const { apiMock, authState, permState } = vi.hoisted(() => ({
|
||||
const { apiMock, authState, permState, policyState } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn<(input: string, init?: RequestInit) => Promise<unknown>>(),
|
||||
authState: { userId: 1 },
|
||||
permState: { isSuperuser: true, denied: [] as string[] },
|
||||
policyState: { passwordLoginEnabled: true },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: apiMock }))
|
||||
@@ -32,6 +33,21 @@ vi.mock('@/features/auth/composables/useAuth', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/useLoginOptions', () => ({
|
||||
useLoginOptions: () => ({
|
||||
loginOptions: computed(() => ({
|
||||
passwordLoginEnabled: policyState.passwordLoginEnabled,
|
||||
allowRegistration: false,
|
||||
oidcProviders: [],
|
||||
})),
|
||||
fetchLoginOptions: vi.fn<() => Promise<{ passwordLoginEnabled: boolean; allowRegistration: boolean; oidcProviders: never[] }>>(async () => ({
|
||||
passwordLoginEnabled: policyState.passwordLoginEnabled,
|
||||
allowRegistration: false,
|
||||
oidcProviders: [],
|
||||
})),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div><slot /></div>' },
|
||||
@@ -122,6 +138,7 @@ beforeEach(() => {
|
||||
permState.isSuperuser = true
|
||||
permState.denied = []
|
||||
authState.userId = 1
|
||||
policyState.passwordLoginEnabled = true
|
||||
stubApi()
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { relativeTimestamp } from '../lib/relative-time'
|
||||
|
||||
const props = defineProps<{ items: UserAttentionItem[]; total: number; busyUserId: number | null }>()
|
||||
const props = defineProps<{ items: UserAttentionItem[]; total: number; busyUserId: number | null; passwordLoginEnabled: boolean }>()
|
||||
const emit = defineEmits<{ unlock: [id: number]; sendResetLink: [id: number]; open: [id: number] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -18,7 +18,7 @@ const collapsed = ref(false)
|
||||
|
||||
/** OIDC and shared accounts have no password here, so there is no link to send. */
|
||||
function isResettable(item: UserAttentionItem): boolean {
|
||||
return item.provisioningMethod !== 'oidc' && item.provisioningMethod !== 'shared'
|
||||
return props.passwordLoginEnabled && item.provisioningMethod !== 'oidc' && item.provisioningMethod !== 'shared'
|
||||
}
|
||||
|
||||
function detail(item: UserAttentionItem): string {
|
||||
@@ -41,6 +41,9 @@ function actionLabel(item: UserAttentionItem): string {
|
||||
}
|
||||
|
||||
function unavailableHint(item: UserAttentionItem): string {
|
||||
if (!props.passwordLoginEnabled && item.provisioningMethod !== 'oidc' && item.provisioningMethod !== 'shared') {
|
||||
return t('adminFeature.usersPage.resetPasswordDisabledHintFull')
|
||||
}
|
||||
return item.provisioningMethod === 'oidc'
|
||||
? t('adminFeature.usersPage.resetPasswordOidcHintFull')
|
||||
: t('adminFeature.usersPage.resetPasswordSharedHintFull')
|
||||
|
||||
@@ -22,6 +22,7 @@ const props = defineProps<{
|
||||
isLocked: (user: UserRow) => boolean
|
||||
isResettable: (user: UserRow) => boolean
|
||||
needsAttention: (user: UserRow) => boolean
|
||||
passwordLoginEnabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -47,6 +48,9 @@ function lastActiveLabel(user: UserRow): string {
|
||||
}
|
||||
|
||||
function resetHint(user: UserRow): string {
|
||||
if (!props.passwordLoginEnabled && user.provisioningMethod !== 'oidc' && user.provisioningMethod !== 'shared') {
|
||||
return t('adminFeature.usersPage.resetPasswordDisabledHintFull')
|
||||
}
|
||||
if (user.provisioningMethod === 'oidc') return t('adminFeature.usersPage.resetPasswordOidcHintFull')
|
||||
return t('adminFeature.usersPage.resetPasswordSharedHintFull')
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThe
|
||||
import { LoginError, useAuth } from './composables/useAuth'
|
||||
import { useOidc } from './composables/useOidc'
|
||||
import { useSetupStatus } from './composables/useSetupStatus'
|
||||
import { useLoginOptions } from './composables/useLoginOptions'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -52,8 +53,11 @@ function closeAll() {
|
||||
}
|
||||
|
||||
const { login } = useAuth()
|
||||
const { getPublicProviders, initiateLogin } = useOidc()
|
||||
const { allowRegistration, setupStatusError } = useSetupStatus()
|
||||
const { initiateLogin } = useOidc()
|
||||
const { setupStatusError } = useSetupStatus()
|
||||
const { loginOptions, loginOptionsError, fetchLoginOptions } = useLoginOptions()
|
||||
const passwordLoginEnabled = computed(() => loginOptions.value?.passwordLoginEnabled === true)
|
||||
const allowRegistration = computed(() => loginOptions.value?.allowRegistration === true)
|
||||
|
||||
const route = useRoute()
|
||||
const justRegistered = computed(() => route.query.registered === '1')
|
||||
@@ -68,6 +72,10 @@ const oidcLoadingSlug = ref<string | null>(null)
|
||||
function resolveLoginError(err: unknown): string {
|
||||
if (!(err instanceof Error)) return t('auth.login.errors.invalidCredentials')
|
||||
|
||||
if (err instanceof LoginError && err.errorCode === LoginErrorCode.PASSWORD_AUTH_DISABLED) {
|
||||
return t('auth.login.errors.passwordDisabled')
|
||||
}
|
||||
|
||||
if (err instanceof LoginError && err.errorCode === LoginErrorCode.ACCOUNT_LOCKED) {
|
||||
const minutes = Math.max(1, Math.ceil((err.retryAfterSeconds ?? 0) / 60))
|
||||
return t('auth.login.errors.accountLocked', { minutes })
|
||||
@@ -85,7 +93,11 @@ function resolveLoginError(err: unknown): string {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
oidcProviders.value = await getPublicProviders()
|
||||
try {
|
||||
oidcProviders.value = (await fetchLoginOptions()).oidcProviders
|
||||
} catch {
|
||||
error.value = t('auth.login.errors.optionsUnavailable')
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -251,7 +263,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
|
||||
{{ t('auth.login.registeredNotice') }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<form v-if="passwordLoginEnabled" @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div class="space-y-1.5 animate-fade-up" style="animation-delay: 80ms">
|
||||
<label for="username" class="text-sm font-medium text-foreground">{{ t('auth.fields.username') }}</label>
|
||||
<input
|
||||
@@ -277,7 +289,9 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
|
||||
</div>
|
||||
|
||||
<div v-if="error" role="alert" class="text-sm text-destructive animate-shake">{{ error }}</div>
|
||||
<div v-if="setupStatusError" class="text-sm text-destructive animate-shake">{{ setupStatusError }}</div>
|
||||
<div v-if="setupStatusError || loginOptionsError" class="text-sm text-destructive animate-shake">
|
||||
{{ t('auth.login.errors.optionsUnavailable') }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
@@ -290,13 +304,13 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
|
||||
</form>
|
||||
|
||||
<template v-if="oidcProviders.length > 0">
|
||||
<div class="flex items-center gap-3 my-6">
|
||||
<div v-if="passwordLoginEnabled" class="flex items-center gap-3 my-6">
|
||||
<div class="flex-1 h-px bg-border" />
|
||||
<span class="text-sm text-muted-foreground">{{ t('auth.login.orDivider') }}</span>
|
||||
<div class="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3" :class="passwordLoginEnabled ? '' : 'mt-2'">
|
||||
<button
|
||||
v-for="provider in oidcProviders"
|
||||
:key="provider.slug"
|
||||
@@ -311,7 +325,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-muted-foreground">
|
||||
<p v-if="passwordLoginEnabled" class="mt-6 text-center text-sm text-muted-foreground">
|
||||
<RouterLink to="/forgot-password" class="text-primary hover:underline">{{ t('auth.login.forgotPassword') }}</RouterLink>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import LoginPage from '../LoginPage.vue'
|
||||
|
||||
const { statusState, routeState } = vi.hoisted(() => ({
|
||||
statusState: { allowRegistration: false },
|
||||
statusState: { allowRegistration: false, passwordLoginEnabled: true },
|
||||
routeState: { query: {} as Record<string, unknown> },
|
||||
}))
|
||||
|
||||
@@ -15,7 +15,22 @@ vi.mock('../composables/useAuth', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useOidc', () => ({
|
||||
useOidc: () => ({ getPublicProviders: vi.fn<() => Promise<unknown[]>>(async () => []), initiateLogin: vi.fn<() => void>() }),
|
||||
useOidc: () => ({ initiateLogin: vi.fn<() => void>() }),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useLoginOptions', () => ({
|
||||
useLoginOptions: () => {
|
||||
const options = {
|
||||
passwordLoginEnabled: statusState.passwordLoginEnabled,
|
||||
allowRegistration: statusState.passwordLoginEnabled && statusState.allowRegistration,
|
||||
oidcProviders: [],
|
||||
}
|
||||
return {
|
||||
loginOptions: ref(options),
|
||||
loginOptionsError: ref(null),
|
||||
fetchLoginOptions: vi.fn<() => Promise<typeof options>>(async () => options),
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useSetupStatus', () => ({
|
||||
@@ -59,6 +74,7 @@ function mountPage() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
statusState.allowRegistration = false
|
||||
statusState.passwordLoginEnabled = true
|
||||
routeState.query = {}
|
||||
})
|
||||
|
||||
@@ -87,6 +103,16 @@ describe('LoginPage sign-up affordance', () => {
|
||||
|
||||
expect(wrapper.findAll('a').some((a) => a.attributes('href') === '/forgot-password')).toBe(true)
|
||||
})
|
||||
|
||||
it('omits every password affordance when password authentication is disabled', async () => {
|
||||
statusState.passwordLoginEnabled = false
|
||||
statusState.allowRegistration = true
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
expect(wrapper.findAll('a').some((a) => ['/register', '/forgot-password'].includes(a.attributes('href') ?? ''))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LoginPage post-registration notice', () => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ref } from 'vue'
|
||||
import type { LoginOptionsResponse } from '@bookorbit/types'
|
||||
|
||||
const loginOptions = ref<LoginOptionsResponse | null>(null)
|
||||
const loginOptionsError = ref<string | null>(null)
|
||||
let inFlight: Promise<LoginOptionsResponse> | null = null
|
||||
|
||||
export function useLoginOptions() {
|
||||
async function fetchLoginOptions(force = false): Promise<LoginOptionsResponse> {
|
||||
if (inFlight) return inFlight
|
||||
if (!force && loginOptions.value) return loginOptions.value
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/login-options', { credentials: 'include' })
|
||||
if (!res.ok) throw new Error('Failed to load sign-in options')
|
||||
const data = (await res.json()) as LoginOptionsResponse
|
||||
loginOptions.value = data
|
||||
loginOptionsError.value = null
|
||||
return data
|
||||
} catch (error) {
|
||||
loginOptions.value = null
|
||||
loginOptionsError.value = error instanceof Error ? error.message : 'Failed to load sign-in options'
|
||||
throw error
|
||||
} finally {
|
||||
inFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return inFlight
|
||||
}
|
||||
|
||||
return { loginOptions, loginOptionsError, fetchLoginOptions }
|
||||
}
|
||||
@@ -38,16 +38,6 @@ export class OidcLoginError extends Error {
|
||||
}
|
||||
|
||||
export function useOidc() {
|
||||
async function getPublicProviders(): Promise<OidcProviderPublic[]> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/app-settings/oidc/providers/public')
|
||||
if (!res.ok) return []
|
||||
return res.json()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function initiateLogin(provider: OidcProviderPublic): Promise<void> {
|
||||
if (!provider.enabled) {
|
||||
throw new OidcLoginError(undefined, 'OIDC provider is not enabled')
|
||||
@@ -112,5 +102,5 @@ export function useOidc() {
|
||||
return res.json() as Promise<OidcCallbackResponse>
|
||||
}
|
||||
|
||||
return { getPublicProviders, initiateLogin, exchangeCode }
|
||||
return { initiateLogin, exchangeCode }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
import { useOnboardingTour } from '@/features/onboarding/composables/useOnboardingTour'
|
||||
import { useLoginOptions } from '@/features/auth/composables/useLoginOptions'
|
||||
|
||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
@@ -25,6 +26,7 @@ const { isDemoRestrictedAccount } = usePermissions()
|
||||
const { open: openChangePassword } = useChangePasswordDialog()
|
||||
const { uploading, removing, uploadAvatar, removeAvatar } = useProfileAvatar()
|
||||
const { resetTour } = useOnboardingTour()
|
||||
const { loginOptions, fetchLoginOptions } = useLoginOptions()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const savingProfile = ref(false)
|
||||
@@ -65,8 +67,13 @@ const nameChanged = computed(() => {
|
||||
const profileChanged = computed(() => nameChanged.value || timezoneChanged.value)
|
||||
const accountEditBlocked = computed(() => isDemoRestrictedAccount.value)
|
||||
const canChangePassword = computed(
|
||||
() => !accountEditBlocked.value && user.value?.provisioningMethod !== 'oidc' && user.value?.provisioningMethod !== 'shared',
|
||||
() =>
|
||||
loginOptions.value?.passwordLoginEnabled === true &&
|
||||
!accountEditBlocked.value &&
|
||||
user.value?.provisioningMethod !== 'oidc' &&
|
||||
user.value?.provisioningMethod !== 'shared',
|
||||
)
|
||||
const needsUnlinkPassword = computed(() => loginOptions.value?.passwordLoginEnabled === true && user.value?.authenticationMethod !== 'oidc')
|
||||
|
||||
const accountTypeLabel = computed(() => {
|
||||
if (user.value?.provisioningMethod === 'oidc') return t('settings.account.profile.accountTypeOidc')
|
||||
@@ -249,12 +256,9 @@ const linkingSlug = ref<string | null>(null)
|
||||
onMounted(async () => {
|
||||
oidcIdentityLoading.value = true
|
||||
try {
|
||||
const [identitiesRes, providersRes] = await Promise.all([
|
||||
api('/api/v1/auth/oidc/identities'),
|
||||
fetch('/api/v1/app-settings/oidc/providers/public'),
|
||||
])
|
||||
const [identitiesRes, options] = await Promise.all([api('/api/v1/auth/oidc/identities'), fetchLoginOptions()])
|
||||
if (identitiesRes.ok) linkedIdentities.value = await identitiesRes.json()
|
||||
if (providersRes.ok) oidcProviders.value = await providersRes.json()
|
||||
oidcProviders.value = options.oidcProviders
|
||||
} finally {
|
||||
oidcIdentityLoading.value = false
|
||||
}
|
||||
@@ -298,13 +302,13 @@ async function initiateOidcLink(provider: OidcProviderPublic) {
|
||||
|
||||
async function confirmUnlink() {
|
||||
if (shouldBlockAccountEdit()) return
|
||||
if (!unlinkPassword.value || !unlinkTarget.value) return
|
||||
if (!unlinkTarget.value || (needsUnlinkPassword.value && !unlinkPassword.value)) return
|
||||
unlinking.value = true
|
||||
try {
|
||||
const res = await api(`/api/v1/auth/oidc/identities/${unlinkTarget.value.providerId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: unlinkPassword.value }),
|
||||
body: JSON.stringify(needsUnlinkPassword.value ? { password: unlinkPassword.value } : {}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
@@ -453,7 +457,10 @@ function closeUnlinkDialog() {
|
||||
<h2 id="account-preferences-heading" class="settings-group-label">{{ t('settings.account.groups.preferences') }}</h2>
|
||||
|
||||
<div class="divide-y divide-border overflow-hidden rounded-lg border border-border bg-card shadow-xs">
|
||||
<div class="flex flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between md:px-5 md:py-5">
|
||||
<div
|
||||
v-if="loginOptions?.passwordLoginEnabled"
|
||||
class="flex flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between md:px-5 md:py-5"
|
||||
>
|
||||
<div class="flex min-w-0 max-w-2xl items-start gap-2.5">
|
||||
<Clock :size="16" class="mt-0.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div class="min-w-0">
|
||||
@@ -618,11 +625,11 @@ function closeUnlinkDialog() {
|
||||
:description="t('settings.account.connectedAccounts.unlinkDialog.description')"
|
||||
:confirm-label="unlinking ? t('settings.account.connectedAccounts.unlinking') : t('settings.account.connectedAccounts.unlink')"
|
||||
:busy="unlinking"
|
||||
:confirm-disabled="!unlinkPassword || accountEditBlocked"
|
||||
:confirm-disabled="(needsUnlinkPassword && !unlinkPassword) || accountEditBlocked"
|
||||
@confirm="confirmUnlink"
|
||||
@cancel="closeUnlinkDialog"
|
||||
>
|
||||
<div class="mt-3 space-y-1.5">
|
||||
<div v-if="needsUnlinkPassword" class="mt-3 space-y-1.5">
|
||||
<label for="account-unlink-password" class="settings-label">
|
||||
{{ t('settings.account.connectedAccounts.unlinkDialog.currentPassword') }}
|
||||
</label>
|
||||
|
||||
@@ -3640,6 +3640,7 @@
|
||||
"resetPasswordSharedHint": "Shared accounts do not have passwords",
|
||||
"resetPasswordOidcHintFull": "OIDC users reset passwords in their identity provider.",
|
||||
"resetPasswordSharedHintFull": "Shared accounts do not have passwords.",
|
||||
"resetPasswordDisabledHintFull": "Password authentication is disabled by the server configuration.",
|
||||
"deleteUserAction": "Delete user",
|
||||
"deleteDialogTitle": "Delete user?",
|
||||
"deleteDialogBody": "Delete user \"{username}\". This action cannot be undone.",
|
||||
@@ -4245,6 +4246,8 @@
|
||||
"errors": {
|
||||
"invalidCredentials": "Invalid username or password",
|
||||
"tooManyAttempts": "Too many login attempts. Please wait a minute and try again.",
|
||||
"passwordDisabled": "Password sign-in is disabled. Use a single sign-on provider.",
|
||||
"optionsUnavailable": "Sign-in options could not be loaded. Please refresh the page or contact your administrator.",
|
||||
"accountLocked": "Your password is correct, but this account is temporarily locked after too many failed sign-in attempts. Try again in {minutes, plural, one {# minute} other {# minutes}}, or reset your password to unlock it now."
|
||||
}
|
||||
},
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import { i18n } from './i18n'
|
||||
import { useLocaleStore } from './stores/locale'
|
||||
import { useAuth } from './features/auth/composables/useAuth'
|
||||
import { useSetupStatus } from './features/auth/composables/useSetupStatus'
|
||||
import { useLoginOptions } from './features/auth/composables/useLoginOptions'
|
||||
|
||||
// Chrome 124+ blocks aria-hidden from being applied to an element that contains
|
||||
// a focused descendant. Reka UI's dialog uses the aria-hidden package which sets
|
||||
@@ -49,7 +50,7 @@ await localeStore.setLocale(localeStore.locale)
|
||||
// app.use(router) triggers initial navigation and guard execution.
|
||||
const { fetchSetupStatus, needsSetup } = useSetupStatus()
|
||||
try {
|
||||
await fetchSetupStatus()
|
||||
await Promise.all([fetchSetupStatus(), useLoginOptions().fetchLoginOptions()])
|
||||
} catch {
|
||||
// If setup-status check fails, continue with normal auth bootstrap.
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { registerAuthGuard } from '../guards/auth.guard'
|
||||
|
||||
const { authState, statusState, openChangePasswordMock } = vi.hoisted(() => ({
|
||||
authState: { user: null as Record<string, unknown> | null },
|
||||
statusState: { needsSetup: false, allowRegistration: false },
|
||||
statusState: { needsSetup: false, allowRegistration: false, passwordLoginEnabled: true },
|
||||
openChangePasswordMock: vi.fn<(force: boolean) => void>(),
|
||||
}))
|
||||
|
||||
@@ -21,6 +21,20 @@ vi.mock('@/features/auth/composables/useSetupStatus', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/useLoginOptions', () => ({
|
||||
useLoginOptions: () => {
|
||||
const options = {
|
||||
passwordLoginEnabled: statusState.passwordLoginEnabled,
|
||||
allowRegistration: statusState.passwordLoginEnabled && statusState.allowRegistration,
|
||||
oidcProviders: [],
|
||||
}
|
||||
return {
|
||||
loginOptions: ref(options),
|
||||
fetchLoginOptions: vi.fn<() => Promise<typeof options>>(async () => options),
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useChangePasswordDialog', () => ({
|
||||
useChangePasswordDialog: () => ({ open: openChangePasswordMock }),
|
||||
}))
|
||||
@@ -35,6 +49,8 @@ function makeRouter() {
|
||||
{ path: '/login', name: 'login', component: blank, meta: { public: true } },
|
||||
{ path: '/register', name: 'register', component: blank, meta: { public: true } },
|
||||
{ path: '/setup', name: 'setup', component: blank, meta: { public: true } },
|
||||
{ path: '/forgot-password', name: 'forgot-password', component: blank, meta: { public: true } },
|
||||
{ path: '/reset-password', name: 'reset-password', component: blank, meta: { public: true } },
|
||||
],
|
||||
})
|
||||
registerAuthGuard(router)
|
||||
@@ -46,6 +62,7 @@ beforeEach(() => {
|
||||
authState.user = null
|
||||
statusState.needsSetup = false
|
||||
statusState.allowRegistration = false
|
||||
statusState.passwordLoginEnabled = true
|
||||
})
|
||||
|
||||
describe('auth guard: /register', () => {
|
||||
@@ -76,6 +93,16 @@ describe('auth guard: /register', () => {
|
||||
expect(router.currentRoute.value.path).toBe('/')
|
||||
})
|
||||
|
||||
it('redirects a visitor to /login when password authentication is disabled', async () => {
|
||||
statusState.allowRegistration = true
|
||||
statusState.passwordLoginEnabled = false
|
||||
const router = makeRouter()
|
||||
|
||||
await router.push('/register')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it('sends a signed-in user home even when registration is closed', async () => {
|
||||
authState.user = { id: 1, isDefaultPassword: false, settings: {} }
|
||||
const router = makeRouter()
|
||||
@@ -95,3 +122,22 @@ describe('auth guard: /register', () => {
|
||||
expect(router.currentRoute.value.path).toBe('/setup')
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth guard: password recovery', () => {
|
||||
it.each(['/forgot-password', '/reset-password?token=existing-token'])('redirects %s when password authentication is disabled', async (path) => {
|
||||
statusState.passwordLoginEnabled = false
|
||||
const router = makeRouter()
|
||||
|
||||
await router.push(path)
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it.each(['/forgot-password', '/reset-password?token=existing-token'])('allows %s when password authentication is enabled', async (path) => {
|
||||
const router = makeRouter()
|
||||
|
||||
await router.push(path)
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { Router } from 'vue-router'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
import { useSetupStatus } from '@/features/auth/composables/useSetupStatus'
|
||||
import { useLoginOptions } from '@/features/auth/composables/useLoginOptions'
|
||||
|
||||
const PASSWORD_ONLY_PUBLIC_PATHS = new Set(['/forgot-password', '/reset-password'])
|
||||
|
||||
function permissionFallback(name: string | undefined) {
|
||||
return name ? { name } : { path: '/' }
|
||||
@@ -9,10 +12,12 @@ function permissionFallback(name: string | undefined) {
|
||||
|
||||
export function registerAuthGuard(router: Router): void {
|
||||
router.beforeEach(async (to) => {
|
||||
const { fetchSetupStatus, allowRegistration } = useSetupStatus()
|
||||
const { fetchSetupStatus } = useSetupStatus()
|
||||
const { fetchLoginOptions, loginOptions } = useLoginOptions()
|
||||
let requiresSetup = false
|
||||
try {
|
||||
requiresSetup = await fetchSetupStatus()
|
||||
const [setup] = await Promise.all([fetchSetupStatus(), fetchLoginOptions()])
|
||||
requiresSetup = setup
|
||||
} catch {
|
||||
// If setup-status cannot be loaded, fall back to normal auth checks.
|
||||
}
|
||||
@@ -28,7 +33,11 @@ export function registerAuthGuard(router: Router): void {
|
||||
if (to.path === '/register') {
|
||||
const { user } = useAuth()
|
||||
if (user.value) return { path: '/' }
|
||||
if (!allowRegistration.value) return { path: '/login' }
|
||||
if (!loginOptions.value?.allowRegistration) return { path: '/login' }
|
||||
}
|
||||
|
||||
if (PASSWORD_ONLY_PUBLIC_PATHS.has(to.path) && loginOptions.value?.passwordLoginEnabled !== true) {
|
||||
return { path: '/login' }
|
||||
}
|
||||
|
||||
if (to.meta.public) return true
|
||||
@@ -39,7 +48,12 @@ export function registerAuthGuard(router: Router): void {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
if (user.value.isDefaultPassword && user.value.provisioningMethod !== 'shared') {
|
||||
if (
|
||||
user.value.isDefaultPassword &&
|
||||
user.value.provisioningMethod !== 'shared' &&
|
||||
user.value.authenticationMethod !== 'oidc' &&
|
||||
loginOptions.value?.passwordLoginEnabled !== false
|
||||
) {
|
||||
useChangePasswordDialog().open(true)
|
||||
// Allow navigation to '/' but block everything else
|
||||
if (to.path !== '/') return { path: '/' }
|
||||
|
||||
Reference in New Issue
Block a user