fix(web): consume OIDC access_token and add dual SSO sign-out

This commit is contained in:
rustmailer
2026-08-03 00:04:51 +08:00
parent 409370be4b
commit e57d1e41e0
4 changed files with 92 additions and 14 deletions
+2
View File
@@ -103,6 +103,8 @@ export interface User {
account_permissions: Record<number, string[]>
created_at: number;
updated_at: number;
sso_id?: string | null;
sso_provider?: string | null;
}
type Theme = 'dark' | 'light'
+75 -10
View File
@@ -21,6 +21,9 @@ import { useNavigate, useLocation } from '@tanstack/react-router'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetToken } from '@/stores/authStore'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useEdition } from '@/hooks/use-edition'
import { useState } from 'react'
interface SignOutDialogProps {
open: boolean
@@ -31,9 +34,17 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
const navigate = useNavigate()
const location = useLocation()
const { t } = useTranslation()
const handleSignOut = () => {
resetToken()
const currentPath = location.href
const { user } = useCurrentUser()
const { isPro, features } = useEdition()
const [isLoading, setIsLoading] = useState(false)
const isSsoUser =
isPro &&
features.includes('sso') &&
!!user?.sso_provider &&
user.sso_provider !== ''
const goToSignIn = (currentPath: string) => {
navigate({
to: '/sign-in',
search: { redirect: currentPath },
@@ -41,19 +52,73 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
})
}
const localSignOut = () => {
resetToken()
goToSignIn(location.href)
}
const handleConfirm = () => {
if (!isSsoUser) {
localSignOut()
return
}
setIsLoading(true)
// Only sign out of bichon; keep the SSO session for one-click sign-in.
fetch('/api/auth/oidc/local-logout', { redirect: 'follow' })
.catch(() => {})
.finally(() => {
setIsLoading(false)
localSignOut()
})
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={t('sign_out.title', 'Sign out')}
desc={t(
'sign_out.desc',
'Are you sure you want to sign out? You will need to sign in again to access your account.'
)}
confirmText={t('sign_out.confirm', 'Sign out')}
desc={
isSsoUser
? t(
'sign_out.sso_desc',
'Signing out of Bichon only keeps your SSO session (e.g. Keycloak) active. For full security, choose "Sign out and end SSO session".'
)
: t(
'sign_out.desc',
'Are you sure you want to sign out? You will need to sign in again to access your account.'
)
}
confirmText={
isSsoUser
? t('sign_out.confirm_sso', 'Sign out of Bichon only')
: t('sign_out.confirm', 'Sign out')
}
destructive
handleConfirm={handleSignOut}
isLoading={isLoading}
handleConfirm={handleConfirm}
className="sm:max-w-sm"
/>
>
{isSsoUser && (
<div className='grid gap-2'>
<button
type='button'
className='inline-flex h-10 items-center justify-center gap-2 rounded-md border border-destructive/50 bg-background px-4 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10'
disabled={isLoading}
onClick={() => {
resetToken()
window.location.href = '/api/auth/oidc/logout'
}}
>
{t('sign_out.full_sign_out', 'Sign out and end SSO session')}
</button>
<p className='text-muted-foreground px-2 text-xs'>
{t(
'sign_out.sso_warning',
'This will end your SSO session (e.g. Keycloak) and sign you out of all applications using it.'
)}
</p>
</div>
)}
</ConfirmDialog>
)
}
+4 -3
View File
@@ -53,11 +53,12 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const { setTheme } = useTheme();
const navigate = useNavigate()
const { t } = useTranslation()
const { isPro } = useEdition()
const { search } = useLocation();
const redirect = toSearchParams(search).get('redirect') || '/';
const { isPro, features } = useEdition()
const ssoEnabled = isPro && features.includes('sso')
const formSchema = getFormSchema(t)
const form = useForm<LoginFormValues>({
resolver: zodResolver(formSchema),
@@ -159,7 +160,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
{t('auth.login')}
</Button>
{isPro && (
{ssoEnabled && (
<Button
variant='outline'
className='mt-2'
+11 -1
View File
@@ -26,7 +26,7 @@ import {
QueryClientProvider,
} from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { resetToken } from '@/stores/authStore'
import { resetToken, setToken } from '@/stores/authStore'
import { toast } from '@/hooks/use-toast'
import { ThemeProvider } from './context/theme-context'
import './index.css'
@@ -117,6 +117,16 @@ const queryClient = new QueryClient({
const basepath = (window as any).__BICHON_BASE__ || '/';
console.log('Current Basepath:', basepath);
// OIDC SSO callback: the Pro server redirects back with ?access_token=.
// Store it and strip it from the URL before the router/auth flow runs.
const ssoToken = new URLSearchParams(window.location.search).get('access_token');
if (ssoToken) {
setToken({ success: true, access_token: ssoToken });
const clean = `${window.location.pathname}${window.location.hash}`;
window.history.replaceState(null, '', clean);
}
// Create a new router instance
const router = createRouter({
routeTree,