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[]> account_permissions: Record<number, string[]>
created_at: number; created_at: number;
updated_at: number; updated_at: number;
sso_id?: string | null;
sso_provider?: string | null;
} }
type Theme = 'dark' | 'light' type Theme = 'dark' | 'light'
+73 -8
View File
@@ -21,6 +21,9 @@ import { useNavigate, useLocation } from '@tanstack/react-router'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetToken } from '@/stores/authStore' import { resetToken } from '@/stores/authStore'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useEdition } from '@/hooks/use-edition'
import { useState } from 'react'
interface SignOutDialogProps { interface SignOutDialogProps {
open: boolean open: boolean
@@ -31,9 +34,17 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { t } = useTranslation() const { t } = useTranslation()
const handleSignOut = () => { const { user } = useCurrentUser()
resetToken() const { isPro, features } = useEdition()
const currentPath = location.href const [isLoading, setIsLoading] = useState(false)
const isSsoUser =
isPro &&
features.includes('sso') &&
!!user?.sso_provider &&
user.sso_provider !== ''
const goToSignIn = (currentPath: string) => {
navigate({ navigate({
to: '/sign-in', to: '/sign-in',
search: { redirect: currentPath }, 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 ( return (
<ConfirmDialog <ConfirmDialog
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={t('sign_out.title', 'Sign out')} title={t('sign_out.title', 'Sign out')}
desc={t( 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', 'sign_out.desc',
'Are you sure you want to sign out? You will need to sign in again to access your account.' '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')} }
confirmText={
isSsoUser
? t('sign_out.confirm_sso', 'Sign out of Bichon only')
: t('sign_out.confirm', 'Sign out')
}
destructive destructive
handleConfirm={handleSignOut} isLoading={isLoading}
handleConfirm={handleConfirm}
className="sm:max-w-sm" 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 { setTheme } = useTheme();
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation() const { t } = useTranslation()
const { isPro } = useEdition()
const { search } = useLocation(); const { search } = useLocation();
const redirect = toSearchParams(search).get('redirect') || '/'; const redirect = toSearchParams(search).get('redirect') || '/';
const { isPro, features } = useEdition()
const ssoEnabled = isPro && features.includes('sso')
const formSchema = getFormSchema(t) const formSchema = getFormSchema(t)
const form = useForm<LoginFormValues>({ const form = useForm<LoginFormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -159,7 +160,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
{t('auth.login')} {t('auth.login')}
</Button> </Button>
{isPro && ( {ssoEnabled && (
<Button <Button
variant='outline' variant='outline'
className='mt-2' className='mt-2'
+11 -1
View File
@@ -26,7 +26,7 @@ import {
QueryClientProvider, QueryClientProvider,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router' 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 { toast } from '@/hooks/use-toast'
import { ThemeProvider } from './context/theme-context' import { ThemeProvider } from './context/theme-context'
import './index.css' import './index.css'
@@ -117,6 +117,16 @@ const queryClient = new QueryClient({
const basepath = (window as any).__BICHON_BASE__ || '/'; const basepath = (window as any).__BICHON_BASE__ || '/';
console.log('Current Basepath:', basepath); 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 // Create a new router instance
const router = createRouter({ const router = createRouter({
routeTree, routeTree,