src/features/applications/management/application-detail.tsx
Rama main · réplica de lectura del repositorio original.
import {
type ApplicationResponse,
deviceCodeGrantType,
tokenExchangeGrantType,
updateApplicationRequestSchema,
} from '@shared/api/applications'
import type { ApiResourceResponse, OrganizationResponse } from '@shared/api/authorization'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate } from '@tanstack/react-router'
import { ArrowLeft, RotateCw, Trash2 } from 'lucide-react'
import { type FormEvent, type ReactNode, useEffect, useId, useState } from 'react'
import { DestructiveConfirmation } from '@/components/destructive-confirmation'
import { Field, TextArea, TextInput } from '@/components/product-form'
import { TableEmptyRow } from '@/components/table-empty-row'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PermissionsPanel } from '@/features/authorization/permissions-panel'
import {
CopyButton,
clientConfig,
clientTypeLabel,
DeleteApplicationDialog,
ErrorState,
LoadingState,
listValue,
MutationError,
SecretDisclosureDialog,
SwitchRow,
} from '@/features/management/dialogs'
import { ownerLabel } from '@/features/management/ownership-controls'
import { navigateConsoleTab } from '@/features/management/resource-components'
import type { ApplicationDetailSection } from '@/features/management/shared'
import { formatDate, nullableString, parseForm, parseLineList, useAdminMutation } from '@/features/management/utils'
import { consoleQueryKeys } from '@/lib/api/console-query-keys'
import {
deleteApplication,
getApplication,
listApiResources,
listApplicationAuthorizations,
listApplicationClientSecrets,
listOrganizations,
revokeApplicationAuthorization,
rotateApplicationClientSecret,
updateApplication,
} from '@/lib/api/management'
import { tt } from '@/lib/i18n'
import { ApplicationFederatedCredentialsPanel } from './application-federated-credentials'
type Editor = 'details' | 'redirects' | 'authorization' | 'visibility' | 'consent' | null
export function ApplicationDetailPage({
applicationId,
organizationId,
section = 'overview',
}: {
applicationId: string
organizationId?: string
section?: ApplicationDetailSection
}) {
const queryClient = useQueryClient()
const navigate = useNavigate()
const [selectedTab, setSelectedTab] = useState<ApplicationDetailSection>(section)
const [editor, setEditor] = useState<Editor>(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const [rotateOpen, setRotateOpen] = useState(false)
const [rotatedSecret, setRotatedSecret] = useState<string | null>(null)
useEffect(() => setSelectedTab(section), [section])
const query = useQuery({
queryKey: [...consoleQueryKeys.applications, applicationId],
queryFn: () => getApplication(applicationId),
})
const organizationsQuery = useQuery({ queryKey: consoleQueryKeys.organizations, queryFn: listOrganizations })
const resourcesQuery = useQuery({ queryKey: consoleQueryKeys.apiResources, queryFn: () => listApiResources() })
const application = query.data
const secretsQuery = useQuery({
queryKey: [...consoleQueryKeys.applications, applicationId, 'client-secrets'],
queryFn: () => listApplicationClientSecrets(applicationId),
enabled: selectedTab === 'oauth' && application?.public === false,
})
const updateMutation = useAdminMutation({
mutationFn: (input: Parameters<typeof updateApplication>[1]) => updateApplication(applicationId, input),
onSuccess: (updated) => {
queryClient.setQueryData([...consoleQueryKeys.applications, applicationId], updated)
setEditor(null)
return queryClient.invalidateQueries({ queryKey: consoleQueryKeys.applications, exact: true })
},
})
const rotateMutation = useAdminMutation({
mutationFn: () => rotateApplicationClientSecret(applicationId),
onSuccess: (result) => {
setRotateOpen(false)
setRotatedSecret(result.clientSecret)
return queryClient.invalidateQueries({
queryKey: [...consoleQueryKeys.applications, applicationId, 'client-secrets'],
})
},
})
const deleteMutation = useMutation({
mutationFn: () => deleteApplication(applicationId),
onSuccess: async () => {
const detailKey = [...consoleQueryKeys.applications, applicationId]
await queryClient.cancelQueries({ queryKey: detailKey })
await queryClient.invalidateQueries({
queryKey: consoleQueryKeys.applications,
exact: true,
refetchType: 'none',
})
if (organizationId) {
await navigate({ params: { organizationId }, to: '/organizations/$organizationId/applications' })
} else {
await navigate({ to: '/console/applications' })
}
queryClient.removeQueries({ queryKey: detailKey })
},
})
if (query.isLoading || organizationsQuery.isLoading || resourcesQuery.isLoading)
return <LoadingState label={tt('Loading application')} />
const loadError = query.error ?? organizationsQuery.error ?? resourcesQuery.error
if (loadError)
return (
<ErrorState
error={loadError}
onRetry={() => Promise.all([query.refetch(), organizationsQuery.refetch(), resourcesQuery.refetch()])}
/>
)
if (!application) return <ErrorState error={new Error(tt('Application not found.'))} />
if (organizationId && application.ownerOrganizationId !== organizationId) {
return <ErrorState error={new Error(tt('Application does not belong to this Organization.'))} />
}
const organizations = organizationsQuery.data?.items ?? []
const machinePrincipalEnabled = application.allowedGrantTypes.some(
(grantType) => grantType === 'client_credentials' || grantType === tokenExchangeGrantType,
)
const userAuthorizationEnabled = application.allowedGrantTypes.some(
(grantType) => grantType === 'authorization_code' || grantType === deviceCodeGrantType,
)
const visibleTab =
(selectedTab === 'permissions' && !machinePrincipalEnabled) ||
(selectedTab === 'authorizations' && !userAuthorizationEnabled)
? 'overview'
: selectedTab
return (
<>
<div className="consoleDetailStack">
{organizationId ? (
<Link
className="consoleBackLink"
params={{ organizationId }}
to="/organizations/$organizationId/applications"
>
<ArrowLeft />
{tt('Applications')}
</Link>
) : (
<Link className="consoleBackLink" to="/console/applications">
<ArrowLeft />
{tt('Applications')}
</Link>
)}
<header className="consoleDetailHeader">
<div>
<div className="flex flex-wrap items-center gap-2">
<h1>{application.name}</h1>
<Badge variant={application.disabled ? 'outline' : 'secondary'}>
{application.disabled ? tt('Disabled') : tt('Enabled')}
</Badge>
</div>
<p>{application.description ?? tt('OIDC client registered in this Realm.')}</p>
<span className="consoleDetailMeta">
{clientTypeLabel(application.clientType)} · {application.clientId}
</span>
</div>
</header>
<Tabs
onValueChange={(value) => {
const next = value as ApplicationDetailSection
setSelectedTab(next)
navigateConsoleTab(
navigate,
organizationId
? `/organizations/${organizationId}/applications/${applicationId}/${next}`
: `/console/applications/${applicationId}/${next}`,
)
}}
value={visibleTab}
>
<TabsList aria-label={tt('Application detail sections')} className="w-full" variant="navigation">
<TabsTrigger value="overview">{tt('Overview')}</TabsTrigger>
<TabsTrigger value="oauth">{tt('OAuth')}</TabsTrigger>
{machinePrincipalEnabled ? <TabsTrigger value="permissions">{tt('Resource access')}</TabsTrigger> : null}
{userAuthorizationEnabled ? (
<TabsTrigger value="authorizations">{tt('User authorizations')}</TabsTrigger>
) : null}
<TabsTrigger value="settings">{tt('Settings')}</TabsTrigger>
</TabsList>
<TabsContent className="mt-5" value="overview">
<ApplicationOverview application={application} organizations={organizations} />
</TabsContent>
<TabsContent className="mt-5" value="oauth">
<ApplicationOAuth
application={application}
onEditAuthorization={() => setEditor('authorization')}
onEditRedirects={() => setEditor('redirects')}
onRotate={() => setRotateOpen(true)}
pending={rotateMutation.isPending}
secrets={secretsQuery.data?.items ?? []}
/>
<ApplicationFederatedCredentialsPanel applicationId={applicationId} />
</TabsContent>
{machinePrincipalEnabled ? (
<TabsContent className="mt-5" value="permissions">
<PermissionsPanel subject={{ type: 'application', id: applicationId, label: application.name }} />
</TabsContent>
) : null}
{userAuthorizationEnabled ? (
<TabsContent className="mt-5" value="authorizations">
<ApplicationAuthorizations applicationId={applicationId} resources={resourcesQuery.data?.items ?? []} />
</TabsContent>
) : null}
<TabsContent className="mt-5" value="settings">
<ApplicationSettings
application={application}
organizations={organizations}
onDelete={() => setDeleteOpen(true)}
onEditVisibility={() => setEditor('visibility')}
onEditConsent={() => setEditor('consent')}
onEditDetails={() => setEditor('details')}
onToggle={() =>
updateMutation.mutate({
disabled: !application.disabled,
disabledReason: application.disabled ? null : 'Disabled by Realm operator',
})
}
pending={updateMutation.isPending}
/>
</TabsContent>
</Tabs>
</div>
<ApplicationEditor
application={application}
editor={editor}
error={updateMutation.errorMessage}
onClose={() => setEditor(null)}
onSave={(input) => updateMutation.mutate(input)}
organizations={organizations}
pending={updateMutation.isPending}
resources={resourcesQuery.data?.items ?? []}
/>
<SecretDisclosureDialog
clientId={application.clientId}
clientSecret={rotatedSecret}
onClose={() => setRotatedSecret(null)}
open={rotatedSecret !== null}
/>
<DestructiveConfirmation
confirmLabel={rotateMutation.isPending ? tt('Rotating…') : tt('Rotate secret')}
description={tt(
'The current client secret will stop working immediately. Update every deployment that uses it with the new secret.',
)}
error={<MutationError error={rotateMutation.error} />}
onClose={() => setRotateOpen(false)}
onConfirm={() => rotateMutation.mutate(undefined)}
open={rotateOpen}
pending={rotateMutation.isPending}
title={tt('Rotate client secret?')}
/>
<DeleteApplicationDialog
applicationName={application.name}
error={deleteMutation.error}
onClose={() => setDeleteOpen(false)}
onConfirm={() => deleteMutation.mutate()}
open={deleteOpen}
pending={deleteMutation.isPending}
/>
</>
)
}
function ApplicationOverview({
application,
organizations,
}: {
application: ApplicationResponse
organizations: OrganizationResponse[]
}) {
return (
<div className="detailFlatRows">
<DetailRow label="Owner" value={ownerLabel(application.ownerOrganizationId, organizations)} />
{isPlatformApplication(application, organizations) ? (
<DetailRow
label="Consent requirement"
value={application.consentRequired ? tt('Required') : tt('Not required')}
/>
) : null}
<DetailRow label="Created" value={formatDate(application.createdAt)} />
<DetailRow label="Updated" value={formatDate(application.updatedAt)} />
</div>
)
}
function ApplicationOAuth({
application,
onEditAuthorization,
onEditRedirects,
onRotate,
pending,
secrets,
}: {
application: ApplicationResponse
onEditAuthorization: () => void
onEditRedirects: () => void
onRotate: () => void
pending: boolean
secrets: Awaited<ReturnType<typeof listApplicationClientSecrets>>['items']
}) {
return (
<div className="detailSections">
{application.clientType === 'machine' ? null : (
<DetailSection
action={
<Button onClick={onEditRedirects} variant="outline">
{tt('Edit')}
</Button>
}
description="Callbacks and browser origins accepted by this client."
title="Redirects and origins"
>
<DetailRow label="Redirect URIs" value={<CodeList values={application.redirectUris} />} />
<DetailRow label="Post sign-out redirects" value={<CodeList values={application.postLogoutRedirectUris} />} />
<DetailRow label="CORS origins" value={<CodeList values={application.corsOrigins} />} />
</DetailSection>
)}
<DetailSection
action={
<Button onClick={onEditAuthorization} variant="outline">
{tt('Edit')}
</Button>
}
description="Type-derived OAuth behavior and Resource Server scope allowlists."
title="Authorization"
>
<DetailRow label="Grant types" value={application.allowedGrantTypes.join(' · ')} />
<DetailRow label="OIDC scopes" value={application.oidcScopes.join(' · ')} />
<DetailRow
label="Resource scope allowlists"
value={
application.resourceScopes.length
? application.resourceScopes
.map((resource) => `${resource.resourceServerId}: ${resource.scopes.join(', ')}`)
.join(' · ')
: tt('None')
}
/>
<DetailRow label="PKCE" value={application.requirePkce ? tt('Required') : tt('Optional')} />
<DetailRow label="Client authentication" value={application.tokenEndpointAuthMethod} />
<DetailRow
action={
application.public ? undefined : (
<Button disabled={pending} onClick={onRotate} variant="outline">
<RotateCw />
{tt('Rotate secret')}
</Button>
)
}
description="Raw secrets are shown once after creation or rotation."
label="Client secret"
value={
application.public
? tt('Not issued for public clients')
: secrets[0]
? tt('Version {{version}} · created {{date}}', {
version: secrets[0].version,
date: formatDate(secrets[0].createdAt),
})
: tt('No active secret')
}
/>
</DetailSection>
<DetailSection description="Standard endpoints used by OIDC clients and SDKs." title="Integration endpoints">
<DetailRow label="Issuer" value={<code>{application.oidc.issuer}</code>} />
<DetailRow label="Discovery" value={<code>{application.oidc.issuer}/.well-known/openid-configuration</code>} />
<CopyButton label={tt('Copy client config')} value={clientConfig(application, null)} />
</DetailSection>
</div>
)
}
function ApplicationAuthorizations({
applicationId,
resources,
}: {
applicationId: string
resources: ApiResourceResponse[]
}) {
const pageSize = 50
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [revokeTarget, setRevokeTarget] = useState<
Awaited<ReturnType<typeof listApplicationAuthorizations>>['items'][number] | null
>(null)
const queryKey = [...consoleQueryKeys.applications, applicationId, 'authorizations', { page, pageSize }]
const query = useQuery({
queryKey,
queryFn: () => listApplicationAuthorizations(applicationId, { status: 'active', page, pageSize }),
})
const revokeMutation = useMutation({
mutationFn: (authorizationId: string) => revokeApplicationAuthorization(applicationId, authorizationId),
onSuccess: async () => {
setRevokeTarget(null)
if (query.data?.items.length === 1 && page > 1) {
setPage(page - 1)
return
}
await queryClient.invalidateQueries({
queryKey: [...consoleQueryKeys.applications, applicationId, 'authorizations'],
})
},
})
if (query.isLoading) return <LoadingState label={tt('Loading authorizations')} />
if (query.error) return <ErrorState error={query.error} onRetry={() => query.refetch()} />
const authorizations = query.data?.items ?? []
const pagination = query.data?.pagination
const resourceById = new Map(resources.map((resource) => [resource.id, resource]))
return (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{tt('User')}</TableHead>
<TableHead>{tt('Resource Server')}</TableHead>
<TableHead>{tt('Granted access')}</TableHead>
<TableHead>{tt('Granted')}</TableHead>
<TableHead>{tt('Expires')}</TableHead>
<TableHead className="w-0">
<span className="sr-only">{tt('Actions')}</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{authorizations.length === 0 ? (
<TableEmptyRow
colSpan={6}
description={tt('User approvals will appear after the first authorization.')}
title={tt('No active authorizations')}
/>
) : (
authorizations.map((authorization) => (
<TableRow key={authorization.id}>
<TableCell>
<div className="flex min-w-40 flex-col whitespace-normal">
<span className="font-medium">{authorization.user.displayName}</span>
<span className="text-xs text-muted-foreground">{authorization.user.email}</span>
</div>
</TableCell>
<TableCell>
{authorization.resourceServerId
? (resourceById.get(authorization.resourceServerId)?.name ?? authorization.resourceServerId)
: tt('OIDC')}
</TableCell>
<TableCell>
<div className="flex max-w-md flex-wrap gap-1 whitespace-normal">
{authorization.scopes.map((access) => (
<Badge key={access} variant="outline">
{access}
</Badge>
))}
</div>
</TableCell>
<TableCell>{formatDate(authorization.grantedAt)}</TableCell>
<TableCell>
{authorization.expiresAt ? formatDate(authorization.expiresAt) : tt('Does not expire')}
</TableCell>
<TableCell>
<Button onClick={() => setRevokeTarget(authorization)} size="sm" variant="outline">
{tt('Revoke')}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 ? (
<div className="flex items-center justify-between border-t px-3 py-2">
<p className="text-xs text-muted-foreground">
{tt('{{start}}–{{end}} of {{total}}', {
start: (pagination.page - 1) * pagination.pageSize + 1,
end: Math.min(pagination.page * pagination.pageSize, pagination.totalItems),
total: pagination.totalItems,
})}
</p>
<div className="flex gap-2">
<Button disabled={page === 1} onClick={() => setPage(Math.max(1, page - 1))} variant="outline">
{tt('Previous')}
</Button>
<Button
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage(page + 1)}
variant="outline"
>
{tt('Next')}
</Button>
</div>
</div>
) : null}
</div>
<DestructiveConfirmation
confirmLabel={revokeMutation.isPending ? tt('Revoking…') : tt('Revoke authorization')}
description={tt(
'This removes {{user}}’s approval and revokes this application’s active access and refresh tokens for the user.',
{ user: revokeTarget?.user.displayName ?? '' },
)}
error={<MutationError error={revokeMutation.error} />}
onClose={() => setRevokeTarget(null)}
onConfirm={() => revokeMutation.mutate(revokeTarget!.id)}
open={revokeTarget !== null}
pending={revokeMutation.isPending}
title={tt('Revoke authorization?')}
/>
</>
)
}
function ApplicationSettings({
application,
organizations,
onDelete,
onEditVisibility,
onEditConsent,
onEditDetails,
onToggle,
pending,
}: {
application: ApplicationResponse
organizations: OrganizationResponse[]
onDelete: () => void
onEditVisibility: () => void
onEditConsent: () => void
onEditDetails: () => void
onToggle: () => void
pending: boolean
}) {
return (
<div className="detailSections">
<DetailSection
action={
<Button onClick={onEditDetails} variant="outline">
{tt('Edit')}
</Button>
}
description="Metadata used to recognize this OIDC client."
title="Application details"
>
<DetailRow label="Name" value={application.name} />
<DetailRow label="Description" value={application.description ?? tt('Not configured')} />
<DetailRow
label="Homepage"
value={application.homepageUrl ? <code>{application.homepageUrl}</code> : tt('Not configured')}
/>
</DetailSection>
<DetailSection description="The owner Organization is fixed when this client is created." title="Ownership">
<DetailRow label="Owner" value={ownerLabel(application.ownerOrganizationId, organizations)} />
</DetailSection>
<DetailSection
action={
<Button onClick={onEditVisibility} variant="outline">
{tt('Edit')}
</Button>
}
description="Control who may authenticate through this client. This is independent from client-secret handling."
title="Visibility"
>
<DetailRow
description={
application.visibility === 'private'
? tt('Only active members of the owner Organization may sign in.')
: tt('Any Realmroot user may sign in without inheriting the owner Organization or Teams.')
}
label="Application visibility"
value={application.visibility === 'private' ? tt('Private') : tt('Public')}
/>
</DetailSection>
{isPlatformApplication(application, organizations) ? (
<DetailSection
action={
<Button onClick={onEditConsent} variant="outline">
{tt('Edit')}
</Button>
}
description="Control whether users review and approve the access this application requests."
title="User consent"
>
<DetailRow
label="Consent requirement"
value={application.consentRequired ? tt('Required') : tt('Not required')}
/>
</DetailSection>
) : null}
<DetailSection description="Control whether this client can begin new authorization flows." title="Status">
<DetailRow
action={
<Button disabled={pending} onClick={onToggle} variant={application.disabled ? 'outline' : 'destructive'}>
{application.disabled ? tt('Enable application') : tt('Disable application')}
</Button>
}
description="Disabling prevents new sign-ins and token requests while preserving configuration and history."
label="Application status"
value={
<Badge variant={application.disabled ? 'outline' : 'secondary'}>
{application.disabled ? tt('Disabled') : tt('Enabled')}
</Badge>
}
/>
</DetailSection>
<DetailSection
description="Permanently remove this client after active credentials and authorizations are revoked."
title="Danger zone"
>
<DetailRow
action={
<Button onClick={onDelete} variant="destructive">
<Trash2 />
{tt('Delete application')}
</Button>
}
description="Removes consent records, secrets, and application configuration."
label="Delete application"
value={tt('Permanent')}
/>
</DetailSection>
</div>
)
}
function ApplicationEditor({
application,
editor,
error,
onClose,
onSave,
organizations,
pending,
resources,
}: {
application: ApplicationResponse
editor: Editor
error?: string | null
onClose: () => void
onSave: (input: Parameters<typeof updateApplication>[1]) => void
organizations: OrganizationResponse[]
pending: boolean
resources: ApiResourceResponse[]
}) {
const title = editorTitle(editor)
return (
<Sheet
onOpenChange={(open) => {
if (!open) onClose()
}}
open={editor !== null}
>
<SheetContent className="flex h-full flex-col overflow-hidden sm:max-w-xl">
<SheetHeader className="shrink-0">
<SheetTitle>{tt(title)}</SheetTitle>
<SheetDescription>{tt(editorDescription(editor))}</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
{editor === 'details' ? (
<EditorForm
id="application-details"
onSubmit={(form) =>
onSave(
parseForm(updateApplicationRequestSchema, {
name: form.get('name'),
description: nullableString(String(form.get('description') ?? '')),
homepageUrl: nullableString(String(form.get('homepageUrl') ?? '')),
}),
)
}
>
<Field label={tt('Name')}>
<TextInput defaultValue={application.name} name="name" required />
</Field>
<Field label={tt('Description')}>
<TextArea defaultValue={application.description ?? ''} name="description" rows={4} />
</Field>
<Field label={tt('Homepage URL')}>
<TextInput defaultValue={application.homepageUrl ?? ''} name="homepageUrl" type="url" />
</Field>
</EditorForm>
) : null}
{editor === 'redirects' ? (
<EditorForm
id="application-redirects"
onSubmit={(form) =>
onSave(
parseForm(updateApplicationRequestSchema, {
redirectUris: parseLineList(String(form.get('redirectUris') ?? '')),
postLogoutRedirectUris: parseLineList(String(form.get('postLogoutRedirectUris') ?? '')),
corsOrigins: parseLineList(String(form.get('corsOrigins') ?? '')),
}),
)
}
>
<Field help={tt('One URI per line.')} label={tt('Redirect URIs')}>
<TextArea
defaultValue={listValue(application.redirectUris, '\n')}
name="redirectUris"
required
rows={5}
/>
</Field>
<Field help={tt('One URI per line.')} label={tt('Post sign-out redirects')}>
<TextArea
defaultValue={listValue(application.postLogoutRedirectUris, '\n')}
name="postLogoutRedirectUris"
rows={4}
/>
</Field>
<Field help={tt('One origin per line.')} label={tt('CORS origins')}>
<TextArea defaultValue={listValue(application.corsOrigins, '\n')} name="corsOrigins" rows={4} />
</Field>
</EditorForm>
) : null}
{editor === 'authorization' ? (
<AuthorizationEditor application={application} onSave={onSave} resources={resources} />
) : null}
{editor === 'visibility' ? <VisibilityEditor application={application} onSave={onSave} /> : null}
{editor === 'consent' && isPlatformApplication(application, organizations) ? (
<ConsentEditor application={application} onSave={onSave} />
) : null}
{error ? (
<p className="px-4 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</div>
<SheetFooter className="shrink-0">
<Button onClick={onClose} variant="outline">
{tt('Cancel')}
</Button>
<Button disabled={pending} form={editorFormId(editor)} type="submit">
{pending ? tt('Saving…') : tt('Save changes')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
function AuthorizationEditor({
application,
onSave,
resources,
}: {
application: ApplicationResponse
onSave: (input: Parameters<typeof updateApplication>[1]) => void
resources: ApiResourceResponse[]
}) {
const [resourceScopes, setResourceScopes] = useState(application.resourceScopes)
const [deviceLoginEnabled, setDeviceLoginEnabled] = useState(
application.allowedGrantTypes.includes(deviceCodeGrantType),
)
return (
<form
className="grid gap-4 px-4 py-5"
id="application-authorization"
onSubmit={(event) => {
event.preventDefault()
onSave({
resourceScopes,
...(application.clientType === 'public_native' ? { deviceLoginEnabled } : {}),
})
}}
>
{application.clientType === 'public_native' ? (
<SwitchRow checked={deviceLoginEnabled} label={tt('Device login')} onCheckedChange={setDeviceLoginEnabled} />
) : null}
{resources.map((resource) => {
const values = resourceScopes.find((item) => item.resourceServerId === resource.id)?.scopes ?? []
const options = (resource.scopeRegistry?.scopes ?? []).map(
(scope) =>
[scope.value, scope.description ? `${scope.value} — ${scope.description}` : scope.value] as [
string,
string,
],
)
return (
<CheckGroup
description={`${resource.visibility === 'private' ? 'Private' : 'Public'} Resource Server`}
key={resource.id}
label={resource.name}
onChange={(nextScopes) =>
setResourceScopes((current) => [
...current.filter((item) => item.resourceServerId !== resource.id),
...(nextScopes.length ? [{ resourceServerId: resource.id, scopes: nextScopes }] : []),
])
}
options={options}
values={values}
/>
)
})}
</form>
)
}
function VisibilityEditor({
application,
onSave,
}: {
application: ApplicationResponse
onSave: (input: Parameters<typeof updateApplication>[1]) => void
}) {
const [visibility, setVisibility] = useState(application.visibility)
const visibilityId = useId()
return (
<form
className="grid gap-4 px-4 py-5"
id="application-visibility"
onSubmit={(event) => {
event.preventDefault()
onSave({ visibility })
}}
>
<RadioGroup onValueChange={(value) => setVisibility(value as 'public' | 'private')} value={visibility}>
<label className="flex items-start gap-3 rounded-lg border p-3" htmlFor={`${visibilityId}-private`}>
<RadioGroupItem id={`${visibilityId}-private`} value="private" />
<span>
<strong className="block text-sm">{tt('Private')}</strong>
<small className="text-muted-foreground">
{tt('Only active members of the owner Organization may authenticate.')}
</small>
</span>
</label>
<label className="flex items-start gap-3 rounded-lg border p-3" htmlFor={`${visibilityId}-public`}>
<RadioGroupItem id={`${visibilityId}-public`} value="public" />
<span>
<strong className="block text-sm">{tt('Public')}</strong>
<small className="text-muted-foreground">
{tt('Any Realmroot user may authenticate; owner Organization and Team claims are not inherited.')}
</small>
</span>
</label>
</RadioGroup>
</form>
)
}
function ConsentEditor({
application,
onSave,
}: {
application: ApplicationResponse
onSave: (input: Parameters<typeof updateApplication>[1]) => void
}) {
const [requirement, setRequirement] = useState(application.consentRequired ? 'required' : 'not-required')
const consentId = useId()
return (
<form
className="grid gap-4 px-4 py-5"
id="application-consent"
onSubmit={(event) => {
event.preventDefault()
onSave({ consentRequired: requirement === 'required' })
}}
>
<div className="grid gap-3">
<strong className="text-sm">{tt('Consent requirement')}</strong>
<RadioGroup onValueChange={setRequirement} value={requirement}>
<label className="flex items-start gap-3 rounded-lg border p-3" htmlFor={`${consentId}-required`}>
<RadioGroupItem id={`${consentId}-required`} value="required" />
<span>
<strong className="block text-sm">{tt('Require user consent')}</strong>
<small className="text-muted-foreground">
{tt('Users review requested scopes on first use and when access expands.')}
</small>
</span>
</label>
<label className="flex items-start gap-3 rounded-lg border p-3" htmlFor={`${consentId}-not-required`}>
<RadioGroupItem id={`${consentId}-not-required`} value="not-required" />
<span>
<strong className="block text-sm">{tt('Do not require user consent')}</strong>
<small className="text-muted-foreground">
{tt('Users continue without a consent prompt. Application and scope policy still apply.')}
</small>
</span>
</label>
</RadioGroup>
</div>
</form>
)
}
function EditorForm({
children,
id,
onSubmit,
}: {
children: ReactNode
id: string
onSubmit: (form: FormData) => void
}) {
return (
<form
className="grid gap-4 px-4 py-5"
id={id}
onSubmit={(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
onSubmit(new FormData(event.currentTarget))
}}
>
{children}
</form>
)
}
function CheckGroup<T extends string>({
description,
disabledValues = [],
label,
onChange,
options,
values,
}: {
description?: string
disabledValues?: T[]
label: string
onChange: (values: T[]) => void
options: Array<[T, string]>
values: T[]
}) {
const groupId = useId()
return (
<fieldset className="grid gap-3">
<legend className="text-sm font-semibold">{tt(label)}</legend>
{description ? <p className="text-xs text-muted-foreground">{tt(description)}</p> : null}
{options.map(([value, option]) => (
<label className="flex items-center gap-3 text-sm" htmlFor={`${groupId}-${value}`} key={value}>
<Checkbox
checked={values.includes(value)}
disabled={disabledValues.includes(value)}
id={`${groupId}-${value}`}
onCheckedChange={(checked) =>
onChange(checked ? [...values, value] : values.filter((current) => current !== value))
}
/>
{tt(option)}
</label>
))}
</fieldset>
)
}
function DetailSection({
action,
children,
description,
title,
}: {
action?: ReactNode
children: ReactNode
description: string
title: string
}) {
return (
<section className="detailSection">
<header>
<div>
<h2>{tt(title)}</h2>
<p>{tt(description)}</p>
</div>
{action}
</header>
<div className="detailFlatRows">{children}</div>
</section>
)
}
function DetailRow({
action,
description,
label,
value,
}: {
action?: ReactNode
description?: string
label: string
value: ReactNode
}) {
return (
<div className="detailFlatRow">
<div>
<strong>{tt(label)}</strong>
{description ? <span>{tt(description)}</span> : null}
</div>
<span>{value}</span>
{action ?? <i />}
</div>
)
}
function CodeList({ values }: { values: string[] }) {
return values.length ? (
<span className="grid gap-1">
{values.map((value) => (
<code key={value}>{value}</code>
))}
</span>
) : (
<span>—</span>
)
}
function isPlatformApplication(application: ApplicationResponse, organizations: OrganizationResponse[]) {
return organizations.some(
(organization) => organization.id === application.ownerOrganizationId && organization.slug === 'realmroot',
)
}
function editorTitle(editor: Editor) {
return (
{
details: 'Edit application details',
redirects: 'Edit redirects and origins',
authorization: 'Edit Resource Server scope allowlists',
visibility: 'Edit visibility',
consent: 'Edit consent policy',
} as Record<Exclude<Editor, null>, string>
)[editor ?? 'details']
}
function editorDescription(editor: Editor) {
return (
{
details: 'Change the name and metadata used to recognize this client.',
redirects: 'Set the exact callbacks and browser origins accepted by Realmroot.',
authorization: 'Choose the Resource Server scopes this application may request.',
visibility: 'Choose whether all Realmroot users or only owner Organization members may authenticate.',
consent: 'Decide whether users approve access on first use and when requested scopes expand.',
} as Record<Exclude<Editor, null>, string>
)[editor ?? 'details']
}
function editorFormId(editor: Editor) {
return editor ? `application-${editor}` : undefined
}