This commit is contained in:
Stephen Zhou
2026-05-22 11:00:13 +08:00
parent be347d27f7
commit aaf50f5441
17 changed files with 408 additions and 208 deletions

View File

@ -0,0 +1,8 @@
import { AccessTab } from '@/features/deployments/detail/access-tab'
export default async function InstanceDetailAccessPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <AccessTab appInstanceId={appInstanceId} />
}

View File

@ -0,0 +1,8 @@
import { DeveloperApiTab } from '@/features/deployments/detail/developer-api-tab'
export default async function InstanceDetailApiPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <DeveloperApiTab appInstanceId={appInstanceId} />
}

View File

@ -0,0 +1,15 @@
'use client'
import { AccessChannelsSection } from './settings-tab/access/channels-section'
import { AccessPermissionsSection } from './settings-tab/access/permissions-section'
export function AccessTab({ appInstanceId }: {
appInstanceId: string
}) {
return (
<div className="mx-auto flex w-full max-w-[1180px] min-w-0 flex-col gap-y-5 px-6 py-6 sm:py-8">
<AccessPermissionsSection appInstanceId={appInstanceId} />
<AccessChannelsSection appInstanceId={appInstanceId} />
</div>
)
}

View File

@ -10,6 +10,7 @@ type SectionProps = {
children: ReactNode
layout?: 'block' | 'row'
tone?: 'default' | 'destructive'
showDivider?: boolean
}
export function SectionState({ children }: {
@ -39,6 +40,7 @@ export function Section({
children,
layout = 'block',
tone = 'default',
showDivider = true,
}: SectionProps) {
const titleClassName = cn(
'system-sm-semibold',
@ -55,7 +57,7 @@ export function Section({
if (layout === 'row') {
return (
<section className="border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
<section className={cn('py-4 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}>
<div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6">
<div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1">
<div className={titleClassName}>
@ -87,7 +89,7 @@ export function Section({
}
return (
<section className="border-b border-divider-subtle py-6 first:pt-0 last:border-b-0 last:pb-0">
<section className={cn('py-6 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}>
<div className="mb-3 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className={titleClassName}>

View File

@ -53,6 +53,18 @@ function VersionsIcon({ className }: TailwindNavIconProps) {
function VersionsSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-stack-fill', className)} />
}
function AccessIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-shield-user-line', className)} />
}
function AccessSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-shield-user-fill', className)} />
}
function ApiIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-code-s-slash-line', className)} />
}
function ApiSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-code-s-slash-fill', className)} />
}
function SettingsIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-settings-3-line', className)} />
}
@ -64,6 +76,8 @@ const TABS: TabDef[] = [
{ key: 'overview', icon: OverviewIcon, selectedIcon: OverviewSelectedIcon },
{ key: 'deploy', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
{ key: 'releases', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
{ key: 'access', icon: AccessIcon, selectedIcon: AccessSelectedIcon },
{ key: 'api', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
{ key: 'settings', icon: SettingsIcon, selectedIcon: SettingsSelectedIcon },
]

View File

@ -0,0 +1,13 @@
'use client'
import { DeveloperApiSection } from './settings-tab/access/developer-api-section'
export function DeveloperApiTab({ appInstanceId }: {
appInstanceId: string
}) {
return (
<div className="mx-auto flex w-full max-w-[1180px] min-w-0 flex-col gap-y-5 px-6 py-6 sm:py-8">
<DeveloperApiSection appInstanceId={appInstanceId} />
</div>
)
}

View File

@ -7,6 +7,7 @@ import useDocumentTitle from '@/hooks/use-document-title'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { DeployDrawer } from '../components/deploy-drawer'
import { DeploymentSidebar } from './deployment-sidebar'
import { DeveloperApiHeaderActions } from './settings-tab/access/developer-api-section'
import { isInstanceDetailTabKey } from './tabs'
export function InstanceDetail({ appInstanceId, children }: {
@ -27,8 +28,17 @@ export function InstanceDetail({ appInstanceId, children }: {
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
<div className="h-full min-w-0 overflow-y-auto">
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-y-0.5 px-6 pt-3 pb-2 2xl:max-w-[1440px]">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
<div className="flex min-w-0 items-start justify-between gap-4">
<div className="min-w-0">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
</div>
{activeTab === 'api' && (
<div className="shrink-0 pt-1.5">
<DeveloperApiHeaderActions appInstanceId={appInstanceId} />
</div>
)}
</div>
</div>
{children}
</div>

View File

@ -22,9 +22,6 @@ import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { isUndeployedDeploymentRow } from '../runtime-status'
import { Section, SectionState } from './common'
import { AccessChannelsSection } from './settings-tab/access/channels-section'
import { DeveloperApiSection } from './settings-tab/access/developer-api-section'
import { AccessPermissionsSection } from './settings-tab/access/permissions-section'
type AppInstanceWithId = AppInstanceBasicInfo & { id: string }
@ -404,9 +401,6 @@ export function SettingsTab({ appInstanceId }: {
}) {
return (
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:py-8">
<AccessPermissionsSection appInstanceId={appInstanceId} />
<AccessChannelsSection appInstanceId={appInstanceId} />
<DeveloperApiSection appInstanceId={appInstanceId} />
<SettingsFormSection appInstanceId={appInstanceId} />
<DeleteInstanceControlSection appInstanceId={appInstanceId} />
</div>

View File

@ -38,26 +38,79 @@ function ApiKeyRow({ appInstanceId, apiKey }: {
}
return (
<div className="flex items-center gap-3 py-1.5">
<div className="flex min-w-35 flex-col">
<span className="system-sm-medium text-text-primary">{apiKey.name || apiKey.id}</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.envPrefix', { env: environmentLabel })}
</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-1 rounded-lg border border-components-input-border-active bg-components-input-bg-normal pr-1 pl-2">
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
{displayValue}
<tr className="border-t border-divider-subtle">
<td className="px-3 py-2.5 align-middle">
<div className="max-w-54 truncate system-sm-medium text-text-primary">
{apiKey.name || apiKey.id}
</div>
</td>
<td className="px-3 py-2.5 align-middle">
<span className="inline-flex h-5 max-w-36 items-center rounded-md bg-background-section-burn px-1.5 system-xs-medium text-text-tertiary">
<span className="truncate">{environmentLabel}</span>
</span>
</td>
<td className="px-3 py-2.5 align-middle">
<div className="flex h-8 min-w-0 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2">
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
{displayValue}
</div>
</div>
</td>
<td className="px-3 py-2.5 text-right align-middle">
<button
type="button"
onClick={handleRevoke}
aria-label={t('access.revoke')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
>
<span className="i-ri-delete-bin-line size-3.5" />
</button>
</div>
</td>
</tr>
)
}
function ApiKeyTableHeader() {
const { t } = useTranslation('deployments')
return (
<thead>
<tr className="bg-background-default-subtle text-left system-xs-medium-uppercase text-text-tertiary">
<th className="w-56 px-3 py-2 font-medium">
{t('access.api.table.name')}
</th>
<th className="w-40 px-3 py-2 font-medium">
{t('access.api.table.environment')}
</th>
<th className="px-3 py-2 font-medium">
{t('access.api.table.key')}
</th>
<th className="w-18 px-3 py-2 text-right font-medium">
{t('access.api.table.action')}
</th>
</tr>
</thead>
)
}
function ApiKeyTable({ appInstanceId, apiKeys }: {
appInstanceId: string
apiKeys: DeveloperApiKeyRow[]
}) {
return (
<div className="overflow-x-auto rounded-lg border border-divider-subtle">
<table className="w-full min-w-175 table-fixed border-collapse">
<ApiKeyTableHeader />
<tbody className="bg-components-panel-bg">
{apiKeys.map(apiKey => (
<ApiKeyRow
key={apiKey.id}
appInstanceId={appInstanceId}
apiKey={apiKey}
/>
))}
</tbody>
</table>
</div>
)
}
@ -67,15 +120,7 @@ export function ApiKeyList({ appInstanceId, apiKeys }: {
apiKeys: DeveloperApiKeyRow[]
}) {
return (
<div className="flex flex-col divide-y divide-divider-subtle">
{apiKeys.map(apiKey => (
<ApiKeyRow
key={apiKey.id}
appInstanceId={appInstanceId}
apiKey={apiKey}
/>
))}
</div>
<ApiKeyTable appInstanceId={appInstanceId} apiKeys={apiKeys} />
)
}

View File

@ -1,5 +1,6 @@
'use client'
import type { ReactNode } from 'react'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
@ -12,8 +13,8 @@ import { CopyPill, EndpointRow } from './common'
import { getUrlOrigin } from './url'
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
{ key: 'webapp', className: 'flex flex-col gap-2' },
{ key: 'cli', className: 'flex flex-col gap-2 border-t border-divider-subtle pt-3' },
{ key: 'webapp' },
{ key: 'cli' },
]
function AccessChannelsSwitch({ appInstanceId, checked, disabled }: {
@ -39,28 +40,55 @@ function AccessChannelsSwitch({ appInstanceId, checked, disabled }: {
function AccessChannelsSkeleton() {
return (
<div className="flex flex-col gap-5">
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
{ACCESS_CHANNEL_SKELETON_SECTIONS.map(section => (
<div
<SkeletonRow
key={section.key}
className={section.className}
className="grid gap-3 border-t border-divider-subtle px-4 py-4 first:border-t-0 lg:grid-cols-[minmax(220px,280px)_minmax(0,1fr)]"
>
<SkeletonRow className="items-center gap-2">
<div className="flex flex-col gap-1.5">
<SkeletonRectangle className="h-3.5 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-5 w-24 animate-pulse rounded-full" />
</SkeletonRow>
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRow className="flex-wrap items-center gap-x-3 gap-y-1.5">
<SkeletonRectangle className="h-3 w-35 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 min-w-65 flex-1 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
<SkeletonRectangle className="h-3 w-40 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}
function ChannelStatusBadge() {
const { t } = useTranslation('deployments')
return (
<span className="inline-flex h-5 w-fit max-w-full items-center rounded-md bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
<span className="truncate">{t('access.channels.followPermission')}</span>
</span>
)
}
function ChannelInfo({ icon, title, description, status }: {
icon: ReactNode
title: string
description: string
status: ReactNode
}) {
return (
<div className="flex min-w-0 items-start gap-2.5">
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary">
{icon}
</span>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="system-sm-medium text-text-primary">{title}</span>
{status}
</div>
<div className="system-xs-regular text-text-tertiary">{description}</div>
</div>
</div>
)
}
export function AccessChannelsSection({
appInstanceId,
}: {
@ -82,7 +110,6 @@ export function AccessChannelsSection({
<Section
title={t('access.channels.title')}
description={t('access.channels.description')}
layout="row"
action={(
accessConfigQuery.isLoading
? <SwitchSkeleton />
@ -101,90 +128,78 @@ export function AccessChannelsSection({
? <SectionState>{t('common.loadFailed')}</SectionState>
: runEnabled
? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3.5">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.runAccess.webapp')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.runAccess.webappDesc')}
</div>
{webappRows.length > 0
? (
<div className="flex flex-col gap-1.5">
{webappRows.map((row) => {
const endpointUrl = webappUrl(row.url)
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
<div className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(220px,280px)_minmax(0,1fr)]">
<ChannelInfo
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
title={t('access.runAccess.webapp')}
description={t('access.runAccess.webappDesc')}
status={<ChannelStatusBadge />}
/>
{webappRows.length > 0
? (
<div className="flex flex-col gap-1.5">
{webappRows.map((row) => {
const endpointUrl = webappUrl(row.url)
return (
<EndpointRow
key={`webapp-${row.environment?.id ?? row.url}`}
envName={environmentName(row.environment)}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
)
})}
</div>
)
: (
<SectionState>
{t('access.runAccess.webappEmpty')}
</SectionState>
)}
</div>
<div className="flex flex-col gap-2 border-t border-divider-subtle pt-3.5">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.cli.title')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.description')}
</div>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-0 flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-download-cloud-2-line size-3.5" />
{t('access.cli.install')}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-book-open-line size-3.5" />
{t('access.cli.docs')}
</a>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.empty')}
</div>
)}
</div>
return (
<EndpointRow
key={`webapp-${row.environment?.id ?? row.url}`}
envName={environmentName(row.environment)}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
)
})}
</div>
)
: (
<SectionState>
{t('access.runAccess.webappEmpty')}
</SectionState>
)}
</div>
<div className="grid gap-3 border-t border-divider-subtle px-4 py-4 lg:grid-cols-[minmax(220px,280px)_minmax(0,1fr)]">
<ChannelInfo
icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />}
title={t('access.cli.title')}
description={t('access.cli.description')}
status={<ChannelStatusBadge />}
/>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-0 flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-download-cloud-2-line size-3.5" />
{t('access.cli.install')}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-book-open-line size-3.5" />
{t('access.cli.docs')}
</a>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.empty')}
</div>
)}
</div>
</div>
)

View File

@ -6,11 +6,11 @@ import type {
} from '@dify/contracts/enterprise/types.gen'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { atom, useAtom, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { Section, SectionState } from '../../common'
import { SectionState } from '../../common'
import { ApiKeyGenerateMenu, ApiKeyList } from './api-keys'
import { CopyPill } from './common'
@ -19,6 +19,8 @@ type CreatedApiToken = {
token: string
}
const createdApiTokenAtom = atom<CreatedApiToken | undefined>(undefined)
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']
function permissionEnvironment(row: EnvironmentAccessRow): AppDeployEnvironment | undefined {
@ -46,6 +48,50 @@ function DeveloperApiSwitch({ appInstanceId, checked, disabled }: {
)
}
export function DeveloperApiHeaderActions({ appInstanceId }: {
appInstanceId: string
}) {
const setCreatedApiToken = useSetAtom(createdApiTokenAtom)
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeployAccessService.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessConfig = accessConfigQuery.data
const apiEnabled = accessConfig?.developerApi?.enabled ?? false
const apiKeys = accessConfig?.developerApi?.apiKeys ?? []
const environments = accessConfig?.permissions
?.map(permissionEnvironment)
.filter((environment): environment is AppDeployEnvironment => Boolean(environment)) ?? []
if (accessConfigQuery.isLoading) {
return (
<div className="flex items-center gap-2">
<SkeletonRectangle className="my-0 h-8 w-32 animate-pulse rounded-lg" />
<SwitchSkeleton />
</div>
)
}
return (
<div className="flex items-center gap-2">
{apiEnabled && (
<ApiKeyGenerateMenu
appInstanceId={appInstanceId}
environments={environments}
apiKeys={apiKeys}
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
/>
)}
<DeveloperApiSwitch
appInstanceId={appInstanceId}
checked={apiEnabled}
disabled={accessConfigQuery.isError}
/>
</div>
)
}
function CreatedApiTokenCard({ token, onDismiss }: {
token: string
onDismiss: () => void
@ -82,18 +128,18 @@ function CreatedApiTokenCard({ token, onDismiss }: {
function DeveloperApiSkeleton() {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-4">
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
<SkeletonRow className="items-center justify-between gap-3">
<SkeletonRow className="items-center justify-between gap-3 rounded-lg border border-divider-subtle bg-background-default-subtle px-4 py-3">
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-3.5 w-28 animate-pulse" />
<SkeletonRectangle className="h-3 w-40 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
</SkeletonRow>
<div className="flex flex-col divide-y divide-divider-subtle">
<div className="flex flex-col gap-2">
{DEVELOPER_API_KEY_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="items-center gap-3 py-1.5">
<SkeletonRow key={key} className="items-center gap-3 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3">
<div className="flex min-w-35 flex-col gap-1.5">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
@ -112,7 +158,7 @@ export function DeveloperApiSection({
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
const [createdApiToken, setCreatedApiToken] = useAtom(createdApiTokenAtom)
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeployAccessService.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
@ -130,51 +176,20 @@ export function DeveloperApiSection({
: undefined
return (
<Section
title={t('access.api.developerTitle')}
description={t('access.api.description')}
layout="row"
action={(
accessConfigQuery.isLoading
? <SwitchSkeleton />
: (
<DeveloperApiSwitch
appInstanceId={appInstanceId}
checked={apiEnabled}
disabled={accessConfigQuery.isError}
/>
)
)}
>
<>
{accessConfigQuery.isLoading
? <DeveloperApiSkeleton />
: accessConfigQuery.isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: apiEnabled
? (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-4">
{apiUrl && (
<CopyPill
label={t('access.api.endpoint')}
value={apiUrl}
/>
)}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.backendTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.keyList')}
</span>
</div>
<ApiKeyGenerateMenu
appInstanceId={appInstanceId}
environments={environments}
apiKeys={apiKeys}
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
/>
</div>
{visibleCreatedApiToken && (
<CreatedApiTokenCard
token={visibleCreatedApiToken}
@ -202,6 +217,6 @@ export function DeveloperApiSection({
{t('access.api.disabled')}
</div>
)}
</Section>
</>
)
}

View File

@ -20,11 +20,15 @@ function hasEnvironment(row: EnvironmentAccessRow): row is EnvironmentAccessRow
function AccessPermissionsSkeleton() {
return (
<div className="flex flex-col gap-3">
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
{ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="flex-wrap items-center gap-x-3 gap-y-1.5">
<SkeletonRectangle className="h-3 w-35 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-55 animate-pulse rounded-lg" />
<SkeletonRow
key={key}
className="grid gap-3 border-t border-divider-subtle px-4 py-3 first:border-t-0 lg:grid-cols-[minmax(140px,180px)_minmax(190px,230px)_minmax(0,1fr)] lg:items-center"
>
<SkeletonRectangle className="h-4 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
@ -49,7 +53,7 @@ export function AccessPermissionsSection({
<Section
title={t('access.permissions.title')}
description={t('access.permissions.description')}
layout="row"
showDivider={false}
>
{accessConfigQuery.isLoading
? <AccessPermissionsSkeleton />
@ -62,7 +66,7 @@ export function AccessPermissionsSection({
</SectionState>
)
: (
<div className="flex flex-col gap-2.5">
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
{permissionRows.map(row => (
<EnvironmentPermissionRow
key={row.environment.id}

View File

@ -412,31 +412,53 @@ export function EnvironmentPermissionRow({
}
return (
<div className="grid gap-x-3 gap-y-2 sm:grid-cols-[minmax(88px,108px)_minmax(0,1fr)] lg:grid-cols-[minmax(88px,108px)_minmax(160px,180px)_minmax(0,1fr)]">
<span className="pt-1.5 system-xs-regular text-text-tertiary">
{environmentName(environment)}
</span>
<div className="grid gap-3 border-t border-divider-subtle px-4 py-3 first:border-t-0 lg:grid-cols-[minmax(140px,180px)_minmax(190px,230px)_minmax(0,1fr)] lg:items-start">
<div className="min-w-0 lg:pt-1.5">
<div className="system-2xs-medium-uppercase text-text-tertiary">
{t('access.permissions.col.environment')}
</div>
<div className="mt-1 flex min-w-0 items-center gap-2">
<span className="i-ri-server-line size-3.5 shrink-0 text-text-tertiary" aria-hidden="true" />
<span className="min-w-0 truncate system-sm-medium text-text-primary">
{environmentName(environment)}
</span>
</div>
</div>
<div className="min-w-0">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">
{t('access.permissions.col.permission')}
</div>
<PermissionPicker
value={permissionKind}
disabled={controlsDisabled}
onChange={handlePermissionChange}
/>
</div>
{permissionKind === 'specific' && (
<div className="min-w-0 sm:col-start-2 lg:col-start-3">
<SubjectPicker
selectedSubjects={subjects}
disabled={controlsDisabled}
onChange={handleSubjectsChange}
/>
{subjects.length === 0 && (
<span className="mt-1.5 block system-xs-regular text-text-tertiary">
{t('access.members.emptySelection')}
</span>
)}
<div className="min-w-0">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">
{t('access.permissions.col.subjects')}
</div>
)}
{permissionKind === 'specific'
? (
<>
<SubjectPicker
selectedSubjects={subjects}
disabled={controlsDisabled}
onChange={handleSubjectsChange}
/>
{subjects.length === 0 && (
<span className="mt-1.5 block system-xs-regular text-text-tertiary">
{t('access.members.emptySelection')}
</span>
)}
</>
)
: (
<div className="flex min-h-8 items-center system-xs-regular text-text-tertiary">
{t(`access.permission.${permissionKind}Desc`)}
</div>
)}
</div>
</div>
)
}

View File

@ -1,4 +1,4 @@
export const INSTANCE_DETAIL_TAB_KEYS = ['overview', 'deploy', 'releases', 'settings'] as const
export const INSTANCE_DETAIL_TAB_KEYS = ['overview', 'deploy', 'releases', 'access', 'api', 'settings'] as const
export type InstanceDetailTabKey = typeof INSTANCE_DETAIL_TAB_KEYS[number]

View File

@ -244,26 +244,29 @@ function DeploymentAccessLinks({ appInstanceId, access, isLoading }: {
const links = [
access?.accessChannelsEnabled && access.webappUrl
? {
href: getInstanceTabHref(appInstanceId, 'overview'),
key: 'webapp',
href: getInstanceTabHref(appInstanceId, 'access'),
label: t('card.access.webApp'),
icon: 'i-ri-global-line',
}
: undefined,
access?.accessChannelsEnabled && access.cliUrl
? {
href: getInstanceTabHref(appInstanceId, 'deploy'),
key: 'cli',
href: getInstanceTabHref(appInstanceId, 'access'),
label: t('card.access.cli'),
icon: 'i-ri-terminal-box-line',
}
: undefined,
access?.developerApiEnabled && access.apiUrl
? {
href: getInstanceTabHref(appInstanceId, 'settings'),
key: 'api',
href: getInstanceTabHref(appInstanceId, 'api'),
label: t('card.access.api'),
icon: 'i-ri-code-s-slash-line',
}
: undefined,
].filter((link): link is { href: string, label: string, icon: string } => Boolean(link))
].filter((link): link is { key: string, href: string, label: string, icon: string } => Boolean(link))
if (links.length === 0)
return <div className="min-w-0 grow" />
@ -271,7 +274,7 @@ function DeploymentAccessLinks({ appInstanceId, access, isLoading }: {
return (
<div className="flex min-w-0 grow items-center gap-2">
{links.map(link => (
<Tooltip key={link.href}>
<Tooltip key={link.key}>
<TooltipTrigger
render={(
<Link
@ -360,12 +363,16 @@ export function InstanceCard({ app }: {
</div>
)
: (
<p
className="mt-2 line-clamp-2 min-h-9 system-xs-regular text-text-tertiary"
title={description || t('card.noDescription')}
>
{description || t('card.noDescription')}
</p>
description
? (
<p
className="mt-2 line-clamp-2 min-h-9 system-xs-regular text-text-tertiary"
title={description}
>
{description}
</p>
)
: <div className="mt-2 min-h-9" />
)}
</Link>

View File

@ -14,7 +14,14 @@
"access.api.newTokenLabel": "Token",
"access.api.newTokenTitle": "API key created",
"access.api.noKeys": "No API keys yet. Create one to start calling the API.",
"access.api.table.action": "Action",
"access.api.table.environment": "Environment",
"access.api.table.key": "API key",
"access.api.table.name": "Name",
"access.api.title": "API",
"access.channels.col.channel": "Channel",
"access.channels.col.endpoint": "Entry point",
"access.channels.col.status": "Status",
"access.channels.description": "WebApp and CLI entry points use the access permissions above.",
"access.channels.disabled": "Access channels are turned off for this instance.",
"access.channels.followPermission": "Follows permissions",
@ -55,6 +62,9 @@
"access.permission.specificDesc": "Pick groups or individual members",
"access.permission.specificUnavailable": "Specific member selection is disabled until real workspace subjects are connected.",
"access.permission.updateFailed": "Failed to update access policy.",
"access.permissions.col.environment": "Environment",
"access.permissions.col.permission": "Access",
"access.permissions.col.subjects": "Allowed subjects",
"access.permissions.description": "Configure who can access this instance in each deployed environment.",
"access.permissions.title": "Access permissions",
"access.revoke": "Revoke",
@ -418,13 +428,17 @@
"status.ready": "Ready",
"status.unknown": "Unknown",
"subtitle": "Deploy and manage your apps across environments.",
"tabs.access.description": "Configure environment permissions and WebApp or CLI access channels.",
"tabs.access.name": "Access",
"tabs.api.description": "Manage HTTP endpoint access and API keys for this instance.",
"tabs.api.name": "Developer API",
"tabs.deploy.description": "Environments this instance is deployed to and their current releases.",
"tabs.deploy.name": "Deploy",
"tabs.overview.description": "Deploy releases and review target environments.",
"tabs.overview.name": "Overview",
"tabs.releases.description": "All releases for this app. Deploy any release to an environment.",
"tabs.releases.name": "Releases",
"tabs.settings.description": "Access, API keys, metadata, and backend-managed settings.",
"tabs.settings.description": "Metadata and backend-managed settings for this instance.",
"tabs.settings.name": "Settings",
"title": "App instances",
"versions.cancelCreate": "Cancel",

View File

@ -14,7 +14,14 @@
"access.api.newTokenLabel": "密钥",
"access.api.newTokenTitle": "API Key 已创建",
"access.api.noKeys": "尚无 API 密钥,创建一个即可调用 API。",
"access.api.table.action": "操作",
"access.api.table.environment": "环境",
"access.api.table.key": "API Key",
"access.api.table.name": "名称",
"access.api.title": "API",
"access.channels.col.channel": "渠道",
"access.channels.col.endpoint": "入口",
"access.channels.col.status": "状态",
"access.channels.description": "WebApp 与 CLI 入口遵循上方访问权限。",
"access.channels.disabled": "该实例的接入渠道已关闭。",
"access.channels.followPermission": "随权限开放",
@ -55,6 +62,9 @@
"access.permission.specificDesc": "选择指定的分组或单个成员",
"access.permission.specificUnavailable": "特定成员暂未启用,需接入真实工作区成员与分组后再开放。",
"access.permission.updateFailed": "更新访问策略失败。",
"access.permissions.col.environment": "环境",
"access.permissions.col.permission": "访问范围",
"access.permissions.col.subjects": "授权对象",
"access.permissions.description": "配置该实例在每个已部署环境中的访问人员。",
"access.permissions.title": "访问权限",
"access.revoke": "撤销",
@ -418,13 +428,17 @@
"status.ready": "就绪",
"status.unknown": "未知",
"subtitle": "在不同环境中部署和管理你的应用。",
"tabs.access.description": "配置环境访问权限,以及 WebApp 或 CLI 接入渠道。",
"tabs.access.name": "访问",
"tabs.api.description": "管理该实例的 HTTP 调用入口和 API Key。",
"tabs.api.name": "开发者 API",
"tabs.deploy.description": "该实例已部署的环境及其当前发布版本。",
"tabs.deploy.name": "部署",
"tabs.overview.description": "部署发布版本并查看目标环境。",
"tabs.overview.name": "概览",
"tabs.releases.description": "此应用的所有发布版本,可将任一发布版本部署到环境。",
"tabs.releases.name": "发布版本",
"tabs.settings.description": "管理接入、API Key、元数据和后端托管设置。",
"tabs.settings.description": "管理该实例的元数据和后端托管设置。",
"tabs.settings.name": "设置",
"title": "应用实例",
"versions.cancelCreate": "取消",