mirror of
https://github.com/langgenius/dify.git
synced 2026-05-22 18:08:40 +08:00
feat(web): device-flow authorize-sso — Avatar card + Button
This commit is contained in:
51
web/app/device/components/__tests__/authorize-sso.spec.tsx
Normal file
51
web/app/device/components/__tests__/authorize-sso.spec.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import AuthorizeSSO from '../authorize-sso'
|
||||
|
||||
const mockCtx = {
|
||||
subject_email: 'gareth@company.com',
|
||||
subject_issuer: 'Okta (okta.company.com)',
|
||||
user_code: 'ABCD-3456',
|
||||
csrf_token: 'tok',
|
||||
expires_at: '2099-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const mockFetchApprovalContext = vi.fn().mockResolvedValue(mockCtx)
|
||||
const mockApproveExternal = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('@/service/device-flow', () => ({
|
||||
fetchApprovalContext: () => mockFetchApprovalContext(),
|
||||
approveExternal: (...args: unknown[]) => mockApproveExternal(...args),
|
||||
DeviceFlowError: class extends Error {
|
||||
code: string
|
||||
status: number
|
||||
constructor(code: string, status = 400) {
|
||||
super(code)
|
||||
this.code = code
|
||||
this.status = status
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AuthorizeSSO', () => {
|
||||
it('renders subject_email and issuer after context loads', async () => {
|
||||
render(<AuthorizeSSO onApproved={vi.fn()} onError={vi.fn()} />)
|
||||
await screen.findByText('gareth@company.com')
|
||||
expect(screen.getByText('Okta (okta.company.com)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders single Authorize button with no Cancel', async () => {
|
||||
render(<AuthorizeSSO onApproved={vi.fn()} onError={vi.fn()} />)
|
||||
await screen.findByRole('button', { name: /Authorize/i })
|
||||
expect(screen.queryByRole('button', { name: /Cancel/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls approveExternal on Authorize click then calls onApproved', async () => {
|
||||
const onApproved = vi.fn()
|
||||
render(<AuthorizeSSO onApproved={onApproved} onError={vi.fn()} />)
|
||||
await screen.findByRole('button', { name: /Authorize/i })
|
||||
await userEvent.click(screen.getByRole('button', { name: /Authorize/i }))
|
||||
await waitFor(() => expect(onApproved).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ApprovalContext } from '@/service/device-flow'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { approveExternal, fetchApprovalContext } from '@/service/device-flow'
|
||||
import { approveErrorCopy } from '../utils/error-copy'
|
||||
|
||||
@ -30,16 +32,22 @@ const AuthorizeSSO: FC<Props> = ({ onApproved, onError }) => {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchApprovalContext()
|
||||
.then((c) => { if (!cancelled) setCtx(c) })
|
||||
.then((c) => {
|
||||
if (!cancelled)
|
||||
setCtx(c)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled)
|
||||
setLoadErr(approveErrorCopy(e))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const approve = async () => {
|
||||
if (!ctx) return
|
||||
if (!ctx)
|
||||
return
|
||||
setBusy(true)
|
||||
try {
|
||||
await approveExternal(ctx, ctx.user_code)
|
||||
@ -53,12 +61,19 @@ const AuthorizeSSO: FC<Props> = ({ onApproved, onError }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// loadErr and loading states render without the icon-circle pattern intentionally —
|
||||
// they occur before the SSO identity is established, so there is no terminal
|
||||
// state to decorate. The page.tsx error_* states cover post-lookup failures.
|
||||
if (loadErr) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-text-primary">This session is no longer valid</h2>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
Run <code className="rounded bg-components-panel-bg px-1">difyctl auth login</code> again to start a new sign-in.
|
||||
Run
|
||||
{' '}
|
||||
<code className="rounded bg-components-input-bg-normal px-1 font-mono">difyctl auth login</code>
|
||||
{' '}
|
||||
again to start a new sign-in.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@ -68,29 +83,40 @@ const AuthorizeSSO: FC<Props> = ({ onApproved, onError }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-text-primary">Authorize Dify CLI</h2>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
Dify CLI (difyctl) is requesting access via SSO. If you did not start
|
||||
this from your terminal, close this tab.
|
||||
difyctl is requesting access via SSO. If you didn't start this from your terminal, close this tab.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg px-4 py-3">
|
||||
<p className="text-sm text-text-secondary">
|
||||
Signed in as <span className="font-medium text-text-primary">{ctx.subject_email}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Issuer: <span className="font-medium text-text-primary">{ctx.subject_issuer}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-background-section-burn px-3 py-2.5">
|
||||
<Avatar
|
||||
size="md"
|
||||
avatar={null}
|
||||
name={ctx.subject_email}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-primary">{ctx.subject_email}</p>
|
||||
<p className="text-xs text-text-secondary">via SSO</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
{ctx.subject_issuer && (
|
||||
<div className="rounded-lg bg-background-section-burn px-3 py-2 text-sm text-text-secondary">
|
||||
Identity provider:
|
||||
{' '}
|
||||
<span className="font-semibold text-text-primary">{ctx.subject_issuer}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="w-full"
|
||||
onClick={approve}
|
||||
disabled={busy}
|
||||
className="rounded-lg bg-components-button-primary-bg px-4 py-3 text-components-button-primary-text font-medium hover:bg-components-button-primary-bg-hover disabled:opacity-50"
|
||||
>
|
||||
Authorize
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user