src/features/account/planned-pages.tsx
Rama main · réplica de lectura del repositorio original.
import type { AccessRequestApproval, Agent, DecideAccessRequest } from '@shared/api/agent-api'
import type { OrganizationAccessLevel } from '@shared/organization-access'
import { Link, useNavigate } from '@tanstack/react-router'
import { Plus } from 'lucide-react'
import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
import { DestructiveConfirmation } from '@/components/destructive-confirmation'
import { Field, SelectInput, TextInput } from '@/components/product-form'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { AccountOrganizationTeam } from '@/lib/api/account'
import {
acceptAccountOrganizationInvitation,
activateAgent,
addAccountOrganizationTeamMember,
cancelAccountOrganizationInvitation,
createAccountOrganization,
createAccountOrganizationTeam,
deactivateAgent,
decideAccountAgentResourceRequest,
deleteAccountOrganization,
deleteAccountOrganizationTeam,
deleteAgent,
inviteAccountOrganizationMember,
leaveAccountOrganization,
rejectAccountOrganizationInvitation,
removeAccountOrganizationMember,
removeAccountOrganizationTeamMember,
revokeAccountApplicationAuthorization,
updateAccountOrganization,
updateAccountOrganizationMemberRole,
updateAccountOrganizationTeam,
} from '@/lib/api/account'
import { toLocalDateTimeValue } from '@/lib/date-time'
import { tt } from '@/lib/i18n'
import * as Drawer from './account-drawer'
import { AccountAccessNavigation } from './account-navigation'
import {
AccountEmptyState,
AccountObjectSection,
AccountPageHeader,
AccountRow,
AccountRows,
AccountSectionContent,
AccountTabContent,
AccountTabs,
} from './account-page'
import { AccountSurface } from './account-surface'
import { AgentEnrollmentGuide } from './agent-enrollment-guide'
import { IdentityMark } from './identity-mark'
import { DestructiveConfirmationDialog, useDestructiveConfirmation } from './primitives'
import {
accountQueryKeys,
useAccountAccessRequests,
useAccountAgents,
useAccountApplicationAuthorizations,
useAccountMutation,
useAccountOrganization,
useAccountOrganizationInvitations,
useAccountOrganizationRoles,
useAccountOrganizations,
useAccountOrganizationTeamMembers,
useAccountOrganizationTeams,
} from './queries'
import type { AccountApplicationAuthorization } from './types'
import { formatDate } from './utils'
export function AccountApplicationsPage() {
const applicationsQuery = useAccountApplicationAuthorizations(true)
const mutate = useAccountMutation()
const [selected, setSelected] = useState<AccountApplicationAuthorization | null>(null)
const [confirmation, setConfirmation] = useDestructiveConfirmation()
const applications = applicationsQuery.data?.items ?? []
return (
<AccountSurface>
{() => (
<>
<AccountPageHeader
description={tt('Review applications authorized to access your Realmroot identity.')}
title={tt('Access management')}
/>
<AccountAccessNavigation section="applications" />
<AccountObjectSection
description={tt('Provider accounts used by Realmroot and Agents are managed separately in Connections.')}
surface
title={tt('Authorized applications')}
>
{applicationsQuery.isLoading ? (
<p className="text-sm text-muted-foreground">{tt('Loading authorized applications…')}</p>
) : null}
{applicationsQuery.error ? (
<p className="text-sm text-destructive" role="alert">
{applicationsQuery.error instanceof Error
? applicationsQuery.error.message
: tt('Unable to load authorized applications.')}
</p>
) : null}
{!applicationsQuery.isLoading && !applicationsQuery.error ? (
<AccountRows>
{applications.map((application) => (
<AccountRow
action={
<Button onClick={() => setSelected(application)} variant="outline">
{tt('Review')}
</Button>
}
description={tt('Authorized {{date}}', { date: formatDate(application.grantedAt) })}
key={application.id}
label={
<span className="accountIdentityLabel">
<IdentityMark name={application.application.name} application />
<span>{application.application.name}</span>
</span>
}
value={<code>{application.scopes.join(' ')}</code>}
/>
))}
{!applications.length ? (
<AccountEmptyState
description={tt('Applications you approve will appear here.')}
title={tt('No authorized applications')}
/>
) : null}
</AccountRows>
) : null}
</AccountObjectSection>
<ApplicationReviewDialog
application={selected}
onClose={() => setSelected(null)}
onRevoke={(application) => {
setConfirmation({
title: tt('Revoke access to {{application}}?', { application: application.application.name }),
description: tt(
'This application will lose access to your identity immediately. You may be asked to authorize it again the next time you use it.',
),
actionLabel: tt('Revoke access'),
onConfirm: async () => {
let failed = false
await mutate(
'Application access revoked.',
() => revokeAccountApplicationAuthorization(application.id),
{
invalidate: [accountQueryKeys.applications],
onError: () => {
failed = true
},
},
)
if (!failed) setSelected(null)
},
})
}}
/>
<DestructiveConfirmationDialog confirmation={confirmation} onClose={() => setConfirmation(null)} />
</>
)}
</AccountSurface>
)
}
function ApplicationReviewDialog({
application,
onClose,
onRevoke,
}: {
application: AccountApplicationAuthorization | null
onClose: () => void
onRevoke: (application: AccountApplicationAuthorization) => void
}) {
return (
<Drawer.Dialog onOpenChange={(open) => !open && onClose()} open={application !== null}>
<Drawer.DialogContent>
<Drawer.DialogHeader>
<Drawer.DialogTitle>{tt('Application authorization')}</Drawer.DialogTitle>
<Drawer.DialogDescription>
{tt('Review the identity access this application can use on your behalf.')}
</Drawer.DialogDescription>
</Drawer.DialogHeader>
{application ? (
<AccountRows className="accountDetailFacts">
<div className="accountDrawerIdentity">
<IdentityMark name={application.application.name} application />
<h2>{application.application.name}</h2>
</div>
<AccountRow label={tt('Application')} value={application.application.name} />
<AccountRow label={tt('Authorized')} value={formatDate(application.grantedAt)} />
<AccountRow
label={tt('Expires')}
value={application.expiresAt ? formatDate(application.expiresAt) : tt('Never')}
/>
<AccountRow label={tt('Scopes')} value={<code>{application.scopes.join(' ')}</code>} />
</AccountRows>
) : null}
<Drawer.DialogFooter>
<Button onClick={onClose} variant="outline">
{tt('Close')}
</Button>
{application ? (
<Button onClick={() => onRevoke(application)} variant="destructive">
{tt('Revoke access')}
</Button>
) : null}
</Drawer.DialogFooter>
</Drawer.DialogContent>
</Drawer.Dialog>
)
}
export function AccountAgentsPage() {
const [tab, setTab] = useState('identities')
const [selected, setSelected] = useState<Agent | null>(null)
const [request, setRequest] = useState<AccessRequestApproval | null>(null)
const agentsQuery = useAccountAgents()
const requestsQuery = useAccountAccessRequests()
const mutate = useAccountMutation()
const [confirmation, setConfirmation] = useDestructiveConfirmation()
const agents = agentsQuery.data?.items ?? []
const requests = requestsQuery.data?.items ?? []
return (
<AccountSurface>
{() => (
<>
<AccountPageHeader
description={tt('Review Agent identities you control and approve new resource access requests.')}
title={tt('Agents')}
action={<AgentEnrollmentGuide />}
/>
<AccountTabs
onValueChange={setTab}
tabs={[
{ value: 'identities', label: tt('My Agents') },
{ value: 'requests', label: tt('Requests · {{count}}', { count: requests.length }) },
{ value: 'activity', label: tt('Activity') },
]}
value={tab}
>
<AccountTabContent surface value="identities">
{agentsQuery.isLoading ? <p role="status">{tt('Loading Agents…')}</p> : null}
{agentsQuery.error ? (
<p role="alert" className="text-destructive">
{agentsQuery.error.message}
</p>
) : null}
<AccountRows>
{agents.map((agent) => (
<AccountRow
action={
<Button onClick={() => setSelected(agent)} variant="outline">
{tt('Manage')}
</Button>
}
key={agent.id}
label={
<span className="accountIdentityLabel">
<IdentityMark name={agent.name} />
<span>
<span>{agent.name}</span>
<small>{agent.subject}</small>
</span>
</span>
}
value={
<Badge variant={agent.status === 'active' ? 'secondary' : 'outline'}>
{tt(agent.status === 'active' ? 'Enabled' : 'Disabled')}
</Badge>
}
/>
))}
{!agentsQuery.isLoading && !agentsQuery.error && !agents.length ? (
<AccountEmptyState
description={tt('Agent identities you control will appear here.')}
title={tt('No Agent identities')}
/>
) : null}
</AccountRows>
</AccountTabContent>
<AccountTabContent surface value="requests">
{requestsQuery.isLoading ? <p role="status">{tt('Loading requests…')}</p> : null}
{requestsQuery.error ? (
<p role="alert" className="text-destructive">
{requestsQuery.error.message}
</p>
) : null}
<AccountRows>
{requests.map((item) => (
<AccountRow
action={<Button onClick={() => setRequest(item)}>{tt('Review request')}</Button>}
description={tt('{{resource}} · {{scopes}}', {
resource: item.authorizationDetail?.name ?? item.resourceServer.name,
scopes: item.scopes.join(' '),
})}
key={item.id}
label={item.agent.name}
value={
<Badge className="bg-amber-50 text-amber-800" variant="secondary">
{tt(item.status)}
</Badge>
}
/>
))}
{!requestsQuery.isLoading && !requestsQuery.error && !requests.length ? (
<AccountEmptyState
description={tt('New resource access requests will appear here.')}
title={tt('No pending requests')}
/>
) : null}
</AccountRows>
</AccountTabContent>
<AccountTabContent surface value="activity">
<AccountEmptyState
description={tt('Agent activity will appear here when events are available.')}
title={tt('No Agent activity')}
/>
</AccountTabContent>
</AccountTabs>
<AgentDialog
agent={selected}
onClose={() => setSelected(null)}
onDelete={async (agent) => {
setConfirmation({
title: tt('Delete {{agent}}?', { agent: agent.name }),
description: tt(
'The Agent disappears from every interface. Hosts and active resource access stop immediately, and it cannot be restored.',
),
actionLabel: tt('Delete Agent'),
onConfirm: async () => {
let failed = false
await mutate('Agent deleted.', () => deleteAgent(agent.id), {
invalidate: [accountQueryKeys.agents],
onError: () => {
failed = true
},
})
if (!failed) setSelected(null)
},
})
}}
onStatusChange={async (agent) => {
await mutate(
agent.status === 'active' ? 'Agent deactivated.' : 'Agent activated.',
() => (agent.status === 'active' ? deactivateAgent(agent.id) : activateAgent(agent.id)),
{ invalidate: [accountQueryKeys.agents] },
)
}}
/>
<DestructiveConfirmationDialog confirmation={confirmation} onClose={() => setConfirmation(null)} />
<AgentRequestDialog
onClose={() => setRequest(null)}
onDecision={async (item, input) => {
let failed = false
await mutate(
input.decision === 'approve' ? 'Request approved.' : 'Request denied.',
() => decideAccountAgentResourceRequest(item.id, input),
{
invalidate: [accountQueryKeys.accessRequests],
onError: () => {
failed = true
},
},
)
if (!failed) setRequest(null)
}}
request={request}
/>
</>
)}
</AccountSurface>
)
}
export function AgentDialog({
agent,
onClose,
onDelete,
onStatusChange,
}: {
agent: Agent | null
onClose: () => void
onDelete: (agent: Agent) => void
onStatusChange: (agent: Agent) => void
}) {
return (
<Drawer.Dialog onOpenChange={(open) => !open && onClose()} open={agent !== null}>
<Drawer.DialogContent className="sm:max-w-lg">
<Drawer.DialogHeader>
<Drawer.DialogTitle>{agent?.name}</Drawer.DialogTitle>
<Drawer.DialogDescription>{agent?.id}</Drawer.DialogDescription>
</Drawer.DialogHeader>
{agent ? (
<AccountRows className="rounded-lg border px-2">
<AccountRow label={tt('Stable subject')} value={<code>{agent.subject}</code>} />
<AccountRow label={tt('Issuer')} value={<code>{agent.issuer}</code>} />
<AccountRow label={tt('Status')} value={tt(agent.status)} />
<AccountRow label={tt('Created')} value={formatDate(agent.createdAt)} />
<AccountRow label={tt('Last updated')} value={formatDate(agent.updatedAt)} />
</AccountRows>
) : null}
<Drawer.DialogFooter>
<Button onClick={onClose} variant="outline">
{tt('Close')}
</Button>
{agent ? (
<>
<Button onClick={() => onStatusChange(agent)} variant="outline">
{tt(agent.status === 'active' ? 'Deactivate Agent' : 'Activate Agent')}
</Button>
<Button onClick={() => onDelete(agent)} variant="destructive">
{tt('Delete Agent')}
</Button>
</>
) : null}
</Drawer.DialogFooter>
</Drawer.DialogContent>
</Drawer.Dialog>
)
}
export function AgentRequestDialog({
onClose,
onDecision,
request,
}: {
onClose: () => void
onDecision: (request: AccessRequestApproval, input: DecideAccessRequest) => void
request: AccessRequestApproval | null
}) {
const [mode, setMode] = useState<'once' | 'until' | 'persistent'>('once')
const [expiresAt, setExpiresAt] = useState('')
const requestId = request?.id
useEffect(() => {
if (!requestId) return
setMode('once')
setExpiresAt('')
}, [requestId])
const expiryIsValid = mode !== 'until' || (expiresAt.length > 0 && new Date(expiresAt).getTime() > Date.now())
return (
<Drawer.Dialog onOpenChange={(next) => !next && onClose()} open={request !== null}>
<Drawer.DialogContent className="sm:max-w-md">
<Drawer.DialogHeader>
<Drawer.DialogTitle>{tt('Review Agent access request')}</Drawer.DialogTitle>
<Drawer.DialogDescription>
{tt('Confirm the Agent, resource, permissions, and access duration before deciding.')}
</Drawer.DialogDescription>
</Drawer.DialogHeader>
{request ? (
<div className="grid gap-4">
<dl className="grid divide-y">
<div className="grid gap-1 pb-3">
<dt className="text-xs text-muted-foreground">{tt('Agent')}</dt>
<dd className="font-medium">{request.agent.name}</dd>
<dd className="break-all font-mono text-xs text-muted-foreground">{request.agent.id}</dd>
</div>
<div className="grid gap-1 py-3">
<dt className="text-xs text-muted-foreground">{tt('Resource server')}</dt>
<dd className="font-medium">{request.authorizationDetail?.name ?? request.resourceServer.name}</dd>
<dd className="break-all font-mono text-xs text-muted-foreground">{request.resourceServer.id}</dd>
</div>
<div className="grid grid-cols-2 gap-4 pt-3">
<div className="grid content-start gap-1">
<dt className="text-xs text-muted-foreground">{tt('Permissions')}</dt>
<dd className="break-words font-mono text-xs">{request.scopes.join(' ')}</dd>
</div>
<div className="grid content-start gap-1">
<dt className="text-xs text-muted-foreground">{tt('Request expires')}</dt>
<dd className="text-xs">{new Date(request.expiresAt).toLocaleString()}</dd>
</div>
</div>
{request.authorizationDetails.length > 0 ? (
<div className="grid gap-1 pt-3">
<dt className="text-xs text-muted-foreground">{tt('Authorization context')}</dt>
<dd className="whitespace-pre-wrap break-all font-mono text-xs">
{request.authorizationDetails.map((detail) => JSON.stringify(detail)).join('\n')}
</dd>
</div>
) : null}
</dl>
<Field label={tt('Access duration')}>
<SelectInput
name="access-duration"
onChange={(event) => setMode(event.target.value as typeof mode)}
value={mode}
>
<option value="once">{tt('One use')}</option>
<option value="until">{tt('Until a date and time')}</option>
<option value="persistent">{tt('Until revoked')}</option>
</SelectInput>
</Field>
{mode === 'until' ? (
<Field label={tt('Expiry date and time')}>
<TextInput
aria-invalid={expiresAt.length > 0 && !expiryIsValid}
min={toLocalDateTimeValue()}
name="expires-at"
onChange={(event) => setExpiresAt(event.target.value)}
required
type="datetime-local"
value={expiresAt}
/>
</Field>
) : null}
</div>
) : null}
<Drawer.DialogFooter>
<Button onClick={onClose} variant="outline">
{tt('Cancel')}
</Button>
{request ? (
<Button onClick={() => onDecision(request, { decision: 'deny' })} variant="destructive">
{tt('Deny')}
</Button>
) : null}
{request ? (
<Button
disabled={!expiryIsValid}
onClick={() =>
onDecision(request, {
decision: 'approve',
mode,
authorizationDetails: request.authorizationDetails,
...(mode === 'until' ? { expiresAt: new Date(expiresAt).toISOString() } : {}),
})
}
>
{tt('Approve')}
</Button>
) : null}
</Drawer.DialogFooter>
</Drawer.DialogContent>
</Drawer.Dialog>
)
}
export function AccountOrganizationsPage() {
const organizationsQuery = useAccountOrganizations()
const invitationsQuery = useAccountOrganizationInvitations()
const mutate = useAccountMutation()
const [createOpen, setCreateOpen] = useState(false)
const organizations = organizationsQuery.data ?? []
const invitations = (invitationsQuery.data ?? []).filter((invitation) => invitation.status === 'pending')
return (
<AccountSurface>
{(_profile, access) => (
<>
<AccountPageHeader
action={
access.canCreateOrganization ? (
<Button onClick={() => setCreateOpen(true)} size="sm">
<Plus />
{tt('New organization')}
</Button>
) : undefined
}
description={tt('Create shared spaces and manage the organizations where you belong.')}
title={tt('Organizations')}
/>
{invitations.length ? (
<AccountObjectSection surface title={tt('Invitations')}>
<AccountRows>
{invitations.map((invitation) => (
<AccountRow
action={
<div className="flex gap-2">
<Button
onClick={() =>
mutate('Invitation declined.', () => rejectAccountOrganizationInvitation(invitation.id), {
invalidate: [accountQueryKeys.organizationInvitations],
})
}
variant="outline"
>
{tt('Decline')}
</Button>
<Button
onClick={() =>
mutate('Invitation accepted.', () => acceptAccountOrganizationInvitation(invitation.id), {
invalidate: [accountQueryKeys.organizationInvitations, accountQueryKeys.organizations],
})
}
>
{tt('Accept')}
</Button>
</div>
}
description={tt('Invited {{date}} · expires {{expires}}', {
date: formatDate(invitation.createdAt),
expires: formatDate(invitation.expiresAt),
})}
key={invitation.id}
label={invitation.organizationName}
value={organizationAccessLevelLabel(organizationAccessLevel(invitation.role))}
/>
))}
</AccountRows>
</AccountObjectSection>
) : null}
{organizations.length ? (
<div className="accountEntityList">
{organizations.map((organization) => (
<section className="accountObjectSection accountOrganizationCard is-surface" key={organization.id}>
<header>
<OrganizationName id={organization.id} name={organization.name} />
</header>
<AccountRows>
<AccountRow label={tt('Slug')} value={<code>{organization.slug}</code>} />
<AccountRow
action={<OrganizationActions id={organization.id} />}
label={tt('Created')}
value={formatDate(organization.createdAt)}
/>
</AccountRows>
</section>
))}
</div>
) : null}
{!organizationsQuery.isLoading && !organizations.length ? (
<AccountEmptyState
description={tt('Create an organization or accept an invitation to get started.')}
title={tt('No organizations yet')}
/>
) : null}
{access.canCreateOrganization ? (
<NewOrganizationDialog
onClose={() => setCreateOpen(false)}
onCreate={async (input) => {
let failed = false
await mutate('Organization created.', () => createAccountOrganization(input), {
invalidate: [accountQueryKeys.organizations],
onError: () => {
failed = true
},
})
if (!failed) setCreateOpen(false)
}}
open={createOpen}
/>
) : null}
</>
)}
</AccountSurface>
)
}
function OrganizationName({ id, name }: { id: string; name: string }) {
return (
<div className="min-w-0">
<strong className="block truncate">{name}</strong>
<span className="block truncate font-mono text-xs text-muted-foreground">{id}</span>
</div>
)
}
function OrganizationActions({ id }: { id: string }) {
return (
<div className="flex justify-end">
<Button asChild variant="outline">
<Link params={{ organizationId: id }} to="/organizations/$organizationId">
{tt('Manage')}
</Link>
</Button>
</div>
)
}
function NewOrganizationDialog({
onClose,
onCreate,
open,
}: {
onClose: () => void
onCreate: (input: { name: string; slug: string }) => void
open: boolean
}) {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
function submit(event: FormEvent) {
event.preventDefault()
const trimmedName = name.trim()
const trimmedSlug = slug.trim()
if (!trimmedName || !trimmedSlug) return
onCreate({ name: trimmedName, slug: trimmedSlug })
setName('')
setSlug('')
setSlugEdited(false)
}
return (
<Dialog onOpenChange={(next) => !next && onClose()} open={open}>
<DialogContent>
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>{tt('New organization')}</DialogTitle>
<DialogDescription>
{tt('Create a shared identity and authorization context. You become its Owner.')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-5">
<Field label={tt('Organization name')}>
<TextInput
name="name"
onChange={(event) => {
const nextName = event.target.value
setName(nextName)
if (!slugEdited) setSlug(organizationSlug(nextName))
}}
required
value={name}
/>
</Field>
<Field label={tt('Slug')}>
<TextInput
name="slug"
onChange={(event) => {
setSlug(event.target.value.toLowerCase())
setSlugEdited(true)
}}
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
placeholder="payments-team"
required
value={slug}
/>
</Field>
</div>
<DialogFooter>
<Button onClick={onClose} type="button" variant="outline">
{tt('Cancel')}
</Button>
<Button type="submit">{tt('Create organization')}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function organizationSlug(name: string) {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function AccountOrganizationDetailPage({
content,
organizationId,
section = 'overview',
}: {
content?: ReactNode
organizationId: string
section?:
| 'overview'
| 'members'
| 'teams'
| 'roles'
| 'applications'
| 'resource-servers'
| 'webhooks'
| 'activity'
| 'settings'
}) {
const navigate = useNavigate()
const organizationQuery = useAccountOrganization(organizationId)
const organizationRolesQuery = useAccountOrganizationRoles(organizationId, section === 'members')
const mutate = useAccountMutation()
const [editOpen, setEditOpen] = useState(false)
const [inviteOpen, setInviteOpen] = useState(false)
const [selectedMember, setSelectedMember] = useState<OrganizationMemberRow | null>(null)
const [selectedInvitation, setSelectedInvitation] = useState<OrganizationInvitationRow | null>(null)
const [confirmation, setConfirmation] = useState<'leave' | 'delete' | null>(null)
const [membershipConfirmation, setMembershipConfirmation] = useState<
| { type: 'remove-member'; member: OrganizationMemberRow }
| { type: 'cancel-invitation'; invitation: OrganizationInvitationRow }
| null
>(null)
const organization = organizationQuery.data
return (
<AccountSurface>
{(profile) => {
if (organizationQuery.isLoading)
return <p className="text-sm text-muted-foreground">{tt('Loading Organization…')}</p>
if (organizationQuery.error || !organization)
return (
<p className="text-sm text-destructive" role="alert">
{organizationQuery.error instanceof Error
? organizationQuery.error.message
: tt('Organization not found.')}
</p>
)
const membership = organization.members.find((member) => member.userId === profile.id)
const accessLevel = organizationAccessLevel(membership?.role)
const canManageOrganization = organizationMemberRoles(membership?.role).some(
(role) => role === 'owner' || role === 'admin',
)
const pendingInvitations = organization.invitations.filter((invitation) => invitation.status === 'pending')
if (content) return content
return (
<>
<AccountPageHeader
description={tt('Manage members, shared authority, and Organization settings.')}
title={organization.name}
/>
<div className="accountTabs">
{section === 'overview' ? (
<AccountSectionContent surface>
<AccountRows>
<AccountRow
description={tt('Controls Organization administration, not business API authority.')}
label={tt('Your access level')}
value={organizationAccessLevelLabel(accessLevel)}
/>
<AccountRow label={tt('Organization ID')} value={<code>{organization.id}</code>} />
<AccountRow label={tt('Slug')} value={<code>{organization.slug}</code>} />
<AccountRow label={tt('Members')} value={String(organization.members.length)} />
<AccountRow label={tt('Pending invitations')} value={String(pendingInvitations.length)} />
<AccountRow label={tt('Created')} value={formatDate(organization.createdAt)} />
</AccountRows>
</AccountSectionContent>
) : null}
{section === 'members' ? (
<AccountSectionContent surface>
<div className="accountTabBody">
{canManageOrganization ? (
<div className="accountTabToolbar">
<Button onClick={() => setInviteOpen(true)} size="sm">
<Plus />
{tt('Invite member')}
</Button>
</div>
) : null}
<OrganizationMembersTable
canManage={canManageOrganization}
currentUserId={profile.id}
invitations={pendingInvitations}
members={organization.members}
onInvitationSelect={setSelectedInvitation}
onMemberSelect={setSelectedMember}
/>
</div>
</AccountSectionContent>
) : null}
{section === 'teams' ? (
<OrganizationTeamsPanel
canManage={canManageOrganization}
members={organization.members}
organizationId={organization.id}
/>
) : null}
{section === 'roles' ? (
<AccountSectionContent surface>
<AccountObjectSection
description={tt('Better Auth Organization Roles assigned to your membership.')}
title={tt('Your Organization Roles')}
>
<AccountRows>
<AccountRow
description={tt('Roles are resolved to scopes for this Organization only.')}
label={tt('Assigned Roles')}
value={<code>{membership?.role ?? 'member'}</code>}
/>
</AccountRows>
</AccountObjectSection>
</AccountSectionContent>
) : null}
{section === 'settings' ? (
<AccountSectionContent surface>
<AccountRows>
<AccountRow
action={
canManageOrganization ? (
<Button onClick={() => setEditOpen(true)} variant="outline">
{tt('Edit')}
</Button>
) : null
}
label={tt('Organization profile')}
value={organization.name}
/>
<AccountRow
action={
accessLevel !== 'owner' ? (
<Button onClick={() => setConfirmation('leave')} variant="destructive">
{tt('Leave')}
</Button>
) : null
}
description={
accessLevel === 'owner'
? tt('Transfer ownership before leaving this Organization.')
: tt('Your Organization-scoped access stops immediately.')
}
label={tt('Leave organization')}
value={organizationAccessLevelLabel(accessLevel)}
/>
{accessLevel === 'owner' ? (
<AccountRow
action={
<Button onClick={() => setConfirmation('delete')} variant="destructive">
{tt('Delete')}
</Button>
}
description={tt('Permanently delete this Organization after resolving its dependencies.')}
label={tt('Delete organization')}
value={tt('Permanent')}
/>
) : null}
</AccountRows>
</AccountSectionContent>
) : null}
</div>
<EditOrganizationDialog
onClose={() => setEditOpen(false)}
onSave={async (input) => {
let failed = false
await mutate('Organization updated.', () => updateAccountOrganization(organization.id, input), {
invalidate: [accountQueryKeys.organizations, [...accountQueryKeys.organizations, organization.id]],
onError: () => {
failed = true
},
})
if (!failed) setEditOpen(false)
}}
open={editOpen}
organization={organization}
/>
<InviteOrganizationMemberDialog
onClose={() => setInviteOpen(false)}
onInvite={async (input) => {
let failed = false
await mutate('Invitation sent.', () => inviteAccountOrganizationMember(organization.id, input), {
invalidate: [[...accountQueryKeys.organizations, organization.id]],
onError: () => {
failed = true
},
})
if (!failed) setInviteOpen(false)
}}
open={inviteOpen}
roleError={organizationRolesQuery.error instanceof Error ? organizationRolesQuery.error.message : null}
roleLoading={organizationRolesQuery.isLoading}
roles={organizationRolesQuery.data?.items ?? []}
/>
<OrganizationMemberDialog
member={selectedMember}
onClose={() => setSelectedMember(null)}
onRemove={(member) => {
setSelectedMember(null)
setMembershipConfirmation({ type: 'remove-member', member })
}}
onSave={async (member, role) => {
let failed = false
const roles = [
...organizationMemberRoles(member.role).filter(
(assigned) => !['owner', 'admin', 'developer', 'member'].includes(assigned),
),
role,
].sort()
await mutate(
'Access level updated.',
() => updateAccountOrganizationMemberRole(organization.id, member.id, roles),
{
invalidate: [[...accountQueryKeys.organizations, organization.id]],
onError: () => {
failed = true
},
},
)
if (!failed) setSelectedMember(null)
}}
/>
<OrganizationInvitationDialog
invitation={selectedInvitation}
onCancel={(invitation) => {
setSelectedInvitation(null)
setMembershipConfirmation({ type: 'cancel-invitation', invitation })
}}
onClose={() => setSelectedInvitation(null)}
/>
<DestructiveConfirmation
confirmLabel={
membershipConfirmation?.type === 'remove-member' ? tt('Remove member') : tt('Cancel invitation')
}
description={
membershipConfirmation?.type === 'remove-member'
? tt('This member immediately loses Organization access and Organization-scoped authority.')
: tt('This invitation can no longer be accepted. You can send a new invitation later.')
}
onClose={() => setMembershipConfirmation(null)}
onConfirm={async () => {
const action = membershipConfirmation
if (!action) return
let failed = false
await mutate(
action.type === 'remove-member' ? 'Member removed.' : 'Invitation canceled.',
async () => {
if (action.type === 'remove-member') {
await removeAccountOrganizationMember(organization.id, action.member.id)
} else {
await cancelAccountOrganizationInvitation(action.invitation.id)
}
},
{
invalidate: [[...accountQueryKeys.organizations, organization.id]],
onError: () => {
failed = true
},
},
)
if (!failed) setMembershipConfirmation(null)
}}
open={membershipConfirmation !== null}
title={
membershipConfirmation?.type === 'remove-member'
? tt('Remove {{name}}?', { name: membershipConfirmation.member.user.name })
: tt('Cancel invitation for {{email}}?', {
email:
membershipConfirmation?.type === 'cancel-invitation'
? membershipConfirmation.invitation.email
: '',
})
}
/>
<DestructiveConfirmation
confirmLabel={confirmation === 'delete' ? tt('Delete organization') : tt('Leave organization')}
description={
confirmation === 'delete'
? tt('This permanently removes the Organization after its dependencies are resolved.')
: tt('Your membership and Organization-scoped access stop immediately.')
}
onClose={() => setConfirmation(null)}
onConfirm={async () => {
const action = confirmation
if (!action) return
let failed = false
await mutate(
action === 'delete' ? 'Organization deleted.' : 'Organization left.',
async () => {
if (action === 'delete') await deleteAccountOrganization(organization.id)
else await leaveAccountOrganization(organization.id)
},
{
invalidateExact: [accountQueryKeys.organizations],
onError: () => {
failed = true
},
},
)
if (!failed) await navigate({ to: '/organizations' })
}}
open={confirmation !== null}
title={
confirmation === 'delete'
? tt('Delete {{name}}?', { name: organization.name })
: tt('Leave {{name}}?', { name: organization.name })
}
/>
</>
)
}}
</AccountSurface>
)
}
function OrganizationTeamsPanel({
canManage,
members,
organizationId,
}: {
canManage: boolean
members: OrganizationMemberRow[]
organizationId: string
}) {
const teamsQuery = useAccountOrganizationTeams(organizationId)
const mutate = useAccountMutation()
const [editingTeam, setEditingTeam] = useState<AccountOrganizationTeam | 'create' | null>(null)
const [managedTeam, setManagedTeam] = useState<AccountOrganizationTeam | null>(null)
const [deleteTeam, setDeleteTeam] = useState<AccountOrganizationTeam | null>(null)
const [memberToAdd, setMemberToAdd] = useState('')
const [teamMembersPage, setTeamMembersPage] = useState(1)
const teamMembersPageSize = 20
const teamMembersQuery = useAccountOrganizationTeamMembers(organizationId, managedTeam?.id ?? null, {
page: teamMembersPage,
pageSize: teamMembersPageSize,
})
const memberByUserId = new Map(members.map((member) => [member.userId, member]))
const teamMemberships = teamMembersQuery.data?.items ?? []
const teamMembersPagination = teamMembersQuery.data?.pagination
const invalidateTeams = [accountQueryKeys.organizationTeams(organizationId)] as const
const closeMemberManagement = () => {
setManagedTeam(null)
setMemberToAdd('')
setTeamMembersPage(1)
}
return (
<AccountSectionContent surface>
<div className="accountTabBody">
{canManage ? (
<div className="accountTabToolbar">
<Button onClick={() => setEditingTeam('create')} size="sm">
<Plus />
{tt('Create Team')}
</Button>
</div>
) : null}
{teamsQuery.isLoading ? <p className="text-sm text-muted-foreground">{tt('Loading Teams…')}</p> : null}
{teamsQuery.error ? (
<p className="text-sm text-destructive" role="alert">
{teamsQuery.error.message}
</p>
) : null}
<AccountRows>
{(teamsQuery.data ?? []).map((team) => (
<AccountRow
action={
canManage ? (
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
setManagedTeam(team)
setMemberToAdd('')
setTeamMembersPage(1)
}}
size="sm"
variant="outline"
>
{tt('Members')}
</Button>
<Button onClick={() => setEditingTeam(team)} size="sm" variant="outline">
{tt('Rename')}
</Button>
<Button onClick={() => setDeleteTeam(team)} size="sm" variant="destructive">
{tt('Delete')}
</Button>
</div>
) : null
}
description={tt('Issued verbatim as an OIDC group for members of this Team.')}
key={team.id}
label={team.name}
value={<code>{team.name}</code>}
/>
))}
{!teamsQuery.isLoading && !teamsQuery.data?.length ? (
<AccountRow
description={tt('Teams are created explicitly; Realmroot does not create a default Team.')}
label={tt('No Teams')}
value="—"
/>
) : null}
</AccountRows>
</div>
<TeamNameDialog
onClose={() => setEditingTeam(null)}
onSave={async (name) => {
const current = editingTeam
if (!current) return
let failed = false
await mutate(
current === 'create' ? 'Team created.' : 'Team renamed.',
() =>
current === 'create'
? createAccountOrganizationTeam(organizationId, name)
: updateAccountOrganizationTeam(current.id, name),
{
invalidate: [...invalidateTeams],
onError: () => {
failed = true
},
},
)
if (!failed) setEditingTeam(null)
}}
team={editingTeam}
/>
<Dialog onOpenChange={(open) => !open && closeMemberManagement()} open={managedTeam !== null}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{tt('Manage {{name}} members', { name: managedTeam?.name ?? '' })}</DialogTitle>
<DialogDescription>{tt('Only Organization members can be added to this OIDC group.')}</DialogDescription>
</DialogHeader>
<form
className="grid gap-3 p-4 pb-0 sm:grid-cols-[1fr_auto] sm:items-end"
onSubmit={(event) => {
event.preventDefault()
const team = managedTeam
if (!team || !memberToAdd) return
let failed = false
void mutate(
'Team member added.',
() => addAccountOrganizationTeamMember(organizationId, team.id, memberToAdd),
{
invalidate: [accountQueryKeys.organizationTeamMembers(team.id)],
onError: () => {
failed = true
},
},
).then(() => {
if (!failed) {
setMemberToAdd('')
setTeamMembersPage(1)
}
})
}}
>
<Field label={tt('Organization member')}>
<SelectInput
disabled={members.length === 0}
onChange={(event) => setMemberToAdd(event.target.value)}
value={memberToAdd}
>
<option disabled value="">
{tt('Select a member')}
</option>
{members.map((member) => (
<option key={member.userId} value={member.userId}>
{member.user.name} ({member.user.email})
</option>
))}
</SelectInput>
</Field>
<Button disabled={!memberToAdd} type="submit">
{tt('Add member')}
</Button>
</form>
<div className="grid max-h-[42vh] gap-2 overflow-y-auto p-4">
{teamMembersQuery.isLoading ? (
<p className="text-sm text-muted-foreground">{tt('Loading Team members…')}</p>
) : null}
{teamMembersQuery.error ? (
<p className="text-sm text-destructive" role="alert">
{teamMembersQuery.error.message}
</p>
) : null}
{!teamMembersQuery.isLoading && !teamMembersQuery.error && teamMemberships.length === 0 ? (
<p className="text-sm text-muted-foreground">{tt('This Team has no members.')}</p>
) : null}
{teamMemberships.map((membership) => {
const member = memberByUserId.get(membership.userId)
return (
<div className="flex items-center justify-between gap-3 rounded-lg border p-3" key={membership.id}>
<span className="min-w-0">
<strong className="block truncate text-sm">{member?.user.name ?? membership.userId}</strong>
{member ? <small className="text-muted-foreground">{member.user.email}</small> : null}
</span>
<Button
disabled={teamMembersQuery.isPlaceholderData}
onClick={() => {
const team = managedTeam
if (!team) return
const moveToPreviousPage = teamMemberships.length === 1 && teamMembersPage > 1
let failed = false
void mutate(
'Team member removed.',
() => removeAccountOrganizationTeamMember(organizationId, team.id, membership.userId),
{
invalidate: [accountQueryKeys.organizationTeamMembers(team.id)],
onError: () => {
failed = true
},
},
).then(() => {
if (!failed && moveToPreviousPage) {
setTeamMembersPage(Math.max(1, teamMembersPage - 1))
}
})
}}
size="sm"
variant="outline"
>
{tt('Remove')}
</Button>
</div>
)
})}
{teamMembersPagination && teamMembersPagination.totalPages > 1 ? (
<div className="flex items-center justify-between gap-3 pt-2">
<p className="text-xs text-muted-foreground">
{tt('{{start}}–{{end}} of {{total}}', {
start: (teamMembersPagination.page - 1) * teamMembersPagination.pageSize + 1,
end: Math.min(
teamMembersPagination.page * teamMembersPagination.pageSize,
teamMembersPagination.totalItems,
),
total: teamMembersPagination.totalItems,
})}
</p>
<div className="flex gap-2">
<Button
disabled={teamMembersQuery.isPlaceholderData || teamMembersPagination.page === 1}
onClick={() => setTeamMembersPage(Math.max(1, teamMembersPagination.page - 1))}
size="sm"
type="button"
variant="outline"
>
{tt('Previous')}
</Button>
<Button
disabled={
teamMembersQuery.isPlaceholderData ||
teamMembersPagination.page >= teamMembersPagination.totalPages
}
onClick={() => setTeamMembersPage(teamMembersPagination.page + 1)}
size="sm"
type="button"
variant="outline"
>
{tt('Next')}
</Button>
</div>
</div>
) : null}
</div>
<DialogFooter>
<Button onClick={closeMemberManagement} variant="outline">
{tt('Done')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DestructiveConfirmation
confirmLabel={tt('Delete Team')}
description={tt('New Tokens will stop containing this group. Update downstream RBAC mappings if needed.')}
onClose={() => setDeleteTeam(null)}
onConfirm={async () => {
if (!deleteTeam) return
let failed = false
await mutate('Team deleted.', () => deleteAccountOrganizationTeam(organizationId, deleteTeam.id), {
invalidate: [...invalidateTeams],
onError: () => {
failed = true
},
})
if (!failed) setDeleteTeam(null)
}}
open={deleteTeam !== null}
title={tt('Delete {{name}}?', { name: deleteTeam?.name ?? '' })}
/>
</AccountSectionContent>
)
}
function TeamNameDialog({
onClose,
onSave,
team,
}: {
onClose: () => void
onSave: (name: string) => void
team: AccountOrganizationTeam | 'create' | null
}) {
const [error, setError] = useState<string | null>(null)
const existing = team && team !== 'create' ? team : null
return (
<Dialog onOpenChange={(open) => !open && onClose()} open={team !== null}>
<DialogContent>
<form
onSubmit={(event) => {
event.preventDefault()
const name = String(new FormData(event.currentTarget).get('name') ?? '').trim()
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
setError(tt('Use lowercase letters, numbers, and single hyphens.'))
return
}
setError(null)
onSave(name)
}}
>
<DialogHeader>
<DialogTitle>{existing ? tt('Rename Team') : tt('Create Team')}</DialogTitle>
<DialogDescription>
{existing
? tt('Renaming changes the OIDC group in newly issued Tokens; synchronize downstream RBAC first.')
: tt('The Team name is issued verbatim as the OIDC group identifier.')}
</DialogDescription>
</DialogHeader>
<div className="p-4">
<Field help={tt('Lowercase kebab-case, for example platform-admins.')} label={tt('Team name')}>
<TextInput defaultValue={existing?.name ?? ''} name="name" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" required />
</Field>
{error ? (
<p className="mt-2 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</div>
<DialogFooter>
<Button onClick={onClose} type="button" variant="outline">
{tt('Cancel')}
</Button>
<Button type="submit">{existing ? tt('Rename Team') : tt('Create Team')}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
type OrganizationMemberRow = {
id: string
userId: string
role: string
createdAt: Date
user: { id: string; name: string; email: string; image?: string | null }
}
type OrganizationInvitationRow = {
id: string
email: string
role: string
status: string
createdAt: Date
expiresAt: Date
}
function OrganizationMembersTable({
canManage,
currentUserId,
invitations,
members,
onInvitationSelect,
onMemberSelect,
}: {
canManage: boolean
currentUserId: string
invitations: OrganizationInvitationRow[]
members: OrganizationMemberRow[]
onInvitationSelect: (invitation: OrganizationInvitationRow) => void
onMemberSelect: (member: OrganizationMemberRow) => void
}) {
return (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{tt('Member')}</TableHead>
<TableHead>{tt('Access level')}</TableHead>
<TableHead>{tt('Status')}</TableHead>
<TableHead>{tt('Added')}</TableHead>
{canManage ? (
<TableHead>
<span className="sr-only">{tt('Actions')}</span>
</TableHead>
) : null}
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.id}>
<TableCell>
<strong>{member.user.name}</strong>
<span className="block text-xs text-muted-foreground">{member.user.email}</span>
</TableCell>
<TableCell>{organizationAccessLevelLabel(organizationAccessLevel(member.role))}</TableCell>
<TableCell>
<Badge variant="secondary">{tt('Active')}</Badge>
</TableCell>
<TableCell>{formatDate(member.createdAt)}</TableCell>
{canManage ? (
<TableCell className="text-right">
{member.userId !== currentUserId ? (
<Button onClick={() => onMemberSelect(member)} size="sm" variant="ghost">
{tt('Manage')}
</Button>
) : null}
</TableCell>
) : null}
</TableRow>
))}
{invitations.map((invitation) => (
<TableRow key={invitation.id}>
<TableCell>
<strong>{invitation.email}</strong>
<span className="block text-xs text-muted-foreground">
{tt('Invitation expires {{date}}', { date: formatDate(invitation.expiresAt) })}
</span>
</TableCell>
<TableCell>{organizationAccessLevelLabel(organizationAccessLevel(invitation.role))}</TableCell>
<TableCell>
<Badge variant="outline">{tt(invitation.status)}</Badge>
</TableCell>
<TableCell>{formatDate(invitation.createdAt)}</TableCell>
{canManage ? (
<TableCell className="text-right">
<Button onClick={() => onInvitationSelect(invitation)} size="sm" variant="ghost">
{tt('Review')}
</Button>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
function organizationMemberRoles(role?: string | null) {
return (role ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean)
}
function organizationAccessLevel(role?: string | null): OrganizationAccessLevel {
return (organizationMemberRoles(role).find((value) => ['owner', 'admin', 'developer', 'member'].includes(value)) ??
'member') as OrganizationAccessLevel
}
function organizationAccessLevelLabel(role?: OrganizationAccessLevel) {
if (role === 'owner') return tt('Owner')
if (role === 'admin') return tt('Administrator')
if (role === 'developer') return tt('Developer')
return tt('Member')
}
function OrganizationMemberDialog({
member,
onClose,
onRemove,
onSave,
}: {
member: OrganizationMemberRow | null
onClose: () => void
onRemove: (member: OrganizationMemberRow) => void
onSave: (member: OrganizationMemberRow, role: OrganizationAccessLevel) => void
}) {
return (
<Dialog
onOpenChange={(open) => {
if (!open) onClose()
}}
open={member !== null}
>
<DialogContent>
{member ? (
<form
onSubmit={(event) => {
event.preventDefault()
const form = new FormData(event.currentTarget)
onSave(member, String(form.get('role')) as OrganizationAccessLevel)
}}
>
<DialogHeader>
<DialogTitle>{tt('Manage {{name}}', { name: member.user.name })}</DialogTitle>
<DialogDescription>{member.user.email}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-5">
<Field label={tt('Access level')}>
<SelectInput defaultValue={organizationAccessLevel(member.role)} name="role">
<option value="owner">{tt('Owner')}</option>
<option value="admin">{tt('Administrator')}</option>
<option value="developer">{tt('Developer')}</option>
<option value="member">{tt('Member')}</option>
</SelectInput>
</Field>
</div>
<DialogFooter className="sm:justify-between">
<Button onClick={() => onRemove(member)} type="button" variant="destructive">
{tt('Remove member')}
</Button>
<div className="flex gap-2">
<Button onClick={onClose} type="button" variant="outline">
{tt('Cancel')}
</Button>
<Button type="submit">{tt('Save access level')}</Button>
</div>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
)
}
function OrganizationInvitationDialog({
invitation,
onCancel,
onClose,
}: {
invitation: OrganizationInvitationRow | null
onCancel: (invitation: OrganizationInvitationRow) => void
onClose: () => void
}) {
return (
<Dialog
onOpenChange={(open) => {
if (!open) onClose()
}}
open={invitation !== null}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{tt('Pending invitation')}</DialogTitle>
<DialogDescription>
{tt('Review the recipient and access level before canceling this invitation.')}
</DialogDescription>
</DialogHeader>
{invitation ? (
<AccountRows className="rounded-lg border px-2">
<AccountRow label={tt('Email')} value={invitation.email} />
<AccountRow
label={tt('Access level')}
value={organizationAccessLevelLabel(organizationAccessLevel(invitation.role))}
/>
<AccountRow label={tt('Expires')} value={formatDate(invitation.expiresAt)} />
</AccountRows>
) : null}
<DialogFooter>
<Button onClick={onClose} variant="outline">
{tt('Close')}
</Button>
{invitation ? (
<Button onClick={() => onCancel(invitation)} variant="destructive">
{tt('Cancel invitation')}
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function EditOrganizationDialog({
onClose,
onSave,
open,
organization,
}: {
onClose: () => void
onSave: (input: { name: string; slug: string }) => void
open: boolean
organization: { name: string; slug: string }
}) {
return (
<Dialog
onOpenChange={(next) => {
if (!next) onClose()
}}
open={open}
>
<DialogContent>
<form
onSubmit={(event) => {
event.preventDefault()
const form = new FormData(event.currentTarget)
onSave({ name: String(form.get('name')), slug: String(form.get('slug')) })
}}
>
<DialogHeader>
<DialogTitle>{tt('Edit organization')}</DialogTitle>
<DialogDescription>
{tt('Update the name and stable slug used to recognize this Organization.')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-5">
<Field label={tt('Name')}>
<TextInput defaultValue={organization.name} name="name" required />
</Field>
<Field label={tt('Slug')}>
<TextInput defaultValue={organization.slug} name="slug" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" required />
</Field>
</div>
<DialogFooter>
<Button onClick={onClose} type="button" variant="outline">
{tt('Cancel')}
</Button>
<Button type="submit">{tt('Save changes')}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function InviteOrganizationMemberDialog({
onClose,
onInvite,
open,
roleError,
roleLoading,
roles,
}: {
onClose: () => void
onInvite: (input: { email: string; roles: string[] }) => void
open: boolean
roleError: string | null
roleLoading: boolean
roles: { key: string; displayName: string }[]
}) {
const [selectionError, setSelectionError] = useState<string | null>(null)
return (
<Dialog
onOpenChange={(next) => {
if (!next) onClose()
}}
open={open}
>
<DialogContent>
<form
onSubmit={(event) => {
event.preventDefault()
const form = new FormData(event.currentTarget)
const selectedRoles = form.getAll('roles').map(String).sort()
if (selectedRoles.length === 0) {
setSelectionError(tt('Select at least one Role.'))
return
}
setSelectionError(null)
onInvite({
email: String(form.get('email')),
roles: selectedRoles,
})
}}
>
<DialogHeader>
<DialogTitle>{tt('Invite member')}</DialogTitle>
<DialogDescription>
{tt('Invite a Realm user and choose the access level used to administer this Organization.')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-5">
<Field label={tt('Email')}>
<TextInput name="email" required type="email" />
</Field>
<Field label={tt('Roles')}>
<div className="grid gap-2 rounded-md border p-3">
{roles.map((role) => (
<label className="flex items-center gap-2 text-sm" key={role.key}>
<input defaultChecked={role.key === 'member'} name="roles" type="checkbox" value={role.key} />
{role.displayName}
</label>
))}
</div>
</Field>
{roleLoading ? <p className="text-sm text-muted-foreground">{tt('Loading Roles…')}</p> : null}
{roleError ? (
<p className="text-sm text-destructive" role="alert">
{roleError}
</p>
) : null}
{selectionError ? (
<p className="text-sm text-destructive" role="alert">
{selectionError}
</p>
) : null}
</div>
<DialogFooter>
<Button onClick={onClose} type="button" variant="outline">
{tt('Cancel')}
</Button>
<Button disabled={roleLoading || Boolean(roleError) || roles.length === 0} type="submit">
{tt('Send invitation')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { AccountOverviewPage } from './overview-page'