import React from 'react'; import superagent from 'superagent'; import { Link } from 'react-router-dom'; import { Container, Segment, Header, Icon, Form, Button, Message, Label, Grid, Divider, Modal, Tab, Loader, Dropdown, Confirm } from 'semantic-ui-react'; import OAuthClientSecretModal from './OAuthClientSecretModal.jsx'; import OAuthGrantsList from './OAuthGrantsList.jsx'; import OAuthAuditLog from './OAuthAuditLog.jsx'; import StepUpModal from './StepUpModal.jsx'; /** * OAuth client editor — handles both create and edit modes. * * Authorization model (driven by the server): * - `client.canManage === true` → form is editable and action buttons render * - `client.canManage === false` → read-only view; edit affordances hidden * - `client.viewerIsSuperUser` → Hard-delete button on already-deleted rows, * plus SuperUser never gets a 428 from the * backend so the step-up modal won't open. * * When a mutation returns 428 `step_up_required` (the Admin-owner path), we * cache the "pending action" and open StepUpModal. On successful verify, * we replay the pending action automatically. */ export default class OAuthClientEditor extends React.Component { constructor(props) { super(props); this.state = { loading: props.mode === 'edit', saving: false, error: null, successMessage: null, scopeCatalog: [], client: null, formName: '', formDescription: '', formLogoUrl: '', formHomepageUrl: '', formRedirectUris: '', formServerIps: '', formSelectedScopes: [], revealOpen: false, revealClientId: null, revealPlaintext: null, revealTitle: null, afterRevealRedirect: null, disableReason: '', disableModalOpen: false, deleteConfirmOpen: false, hardDeleteConfirmOpen: false, // Step-up state: when a mutation returns 428 we cache the intent // here and open the modal. On `onVerified` we replay pendingAction. stepUpOpen: false, pendingAction: null }; } async componentDidMount() { await this.fetchScopeCatalog(); if (this.props.mode === 'edit' && this.props.match?.params?.uniqueId) { await this.fetchClient(this.props.match.params.uniqueId); } } async fetchScopeCatalog() { try { const res = await superagent.get('/api/admin/oauth/clients/scopes').accept('json'); this.setState({ scopeCatalog: res.body.scopes || [] }); } catch (e) { console.error('Failed to fetch scope catalog', e); } } async fetchClient(uniqueId) { this.setState({ loading: true, error: null }); try { const res = await superagent.get(`/api/admin/oauth/clients/${uniqueId}`).accept('json'); const client = res.body; this.setState({ loading: false, client, formName: client.name, formDescription: client.description || '', formLogoUrl: client.logoUrl || '', formHomepageUrl: client.homepageUrl || '', formRedirectUris: (client.redirectUris || []).join('\n'), formServerIps: (client.serverIps || []).join('\n'), formSelectedScopes: client.allowedScopes || [] }); } catch (e) { this.setState({ loading: false, error: e.response?.body?.message || e.message }); } } buildPayload() { const splitList = (s) => s.split(/[\n,]+/).map(x => x.trim()).filter(x => x.length > 0); return { name: this.state.formName, description: this.state.formDescription || null, logoUrl: this.state.formLogoUrl || null, homepageUrl: this.state.formHomepageUrl || null, redirectUris: splitList(this.state.formRedirectUris), allowedScopes: this.state.formSelectedScopes, serverIps: splitList(this.state.formServerIps) }; } // Wraps a mutation so that a 428 response opens the step-up modal and // caches the action for replay. Non-428 errors surface normally. async runWithStepUp(actionKey, fn) { try { await fn(); } catch (e) { if (e.response?.status === 428) { this.setState({ stepUpOpen: true, pendingAction: actionKey, saving: false }); return; } throw e; } } onStepUpVerified() { const action = this.state.pendingAction; this.setState({ stepUpOpen: false, pendingAction: null }); if (!action) return; // Replay the original mutation. Each branch re-issues the same // request — the unlock is now in Redis, so the server will proceed. switch (action) { case 'save': this.onSave(); break; case 'rotate': this.onRotate(); break; case 'disable': this.onDisable(); break; case 'enable': this.onEnable(); break; case 'delete': this.onDelete(); break; default: break; } } async onCreate() { this.setState({ saving: true, error: null }); try { const res = await superagent .post('/api/admin/oauth/clients') .accept('json') .send(this.buildPayload()); const created = res.body; this.setState({ saving: false, revealOpen: true, revealClientId: created.clientId, revealPlaintext: created.plaintextSecret, revealTitle: 'Client created — save the secret now', afterRevealRedirect: `/developers/apps/${created.uniqueId}` }); } catch (e) { this.setState({ saving: false, error: e.response?.body?.message || e.message }); } } async onSave() { if (!this.state.client) return; this.setState({ saving: true, error: null, successMessage: null }); try { await this.runWithStepUp('save', async () => { await superagent .put(`/api/admin/oauth/clients/${this.state.client.uniqueId}`) .accept('json') .send(this.buildPayload()); await this.fetchClient(this.state.client.uniqueId); this.setState({ saving: false, successMessage: 'Saved.' }); }); } catch (e) { this.setState({ saving: false, error: e.response?.body?.message || e.message }); } } async onRotate() { if (!this.state.client) return; this.setState({ saving: true, error: null }); try { await this.runWithStepUp('rotate', async () => { const res = await superagent .post(`/api/admin/oauth/clients/${this.state.client.uniqueId}/rotate_secret`) .accept('json'); const rotated = res.body; this.setState({ saving: false, revealOpen: true, revealClientId: rotated.clientId, revealPlaintext: rotated.plaintextSecret, revealTitle: 'Secret rotated — save the new secret now', afterRevealRedirect: null }); }); } catch (e) { this.setState({ saving: false, error: e.response?.body?.message || e.message }); } } async onDisable() { if (!this.state.client) return; this.setState({ saving: true, error: null, disableModalOpen: false }); try { await this.runWithStepUp('disable', async () => { await superagent .post(`/api/admin/oauth/clients/${this.state.client.uniqueId}/disable`) .accept('json') .send({ reason: this.state.disableReason || 'disabled via dashboard' }); await this.fetchClient(this.state.client.uniqueId); this.setState({ saving: false, successMessage: 'Client disabled.', disableReason: '' }); }); } catch (e) { this.setState({ saving: false, error: e.response?.body?.message || e.message }); } } async onEnable() { if (!this.state.client) return; this.setState({ saving: true, error: null }); try { await this.runWithStepUp('enable', async () => { await superagent.post(`/api/admin/oauth/clients/${this.state.client.uniqueId}/enable`).accept('json'); await this.fetchClient(this.state.client.uniqueId); this.setState({ saving: false, successMessage: 'Client enabled.' }); }); } catch (e) { this.setState({ saving: false, error: e.response?.body?.message || e.message }); } } async onDelete() { if (!this.state.client) return; this.setState({ saving: true, error: null, deleteConfirmOpen: false }); try { await this.runWithStepUp('delete', async () => { await superagent.delete(`/api/admin/oauth/clients/${this.state.client.uniqueId}`).accept('json'); window.location.href = '/developers'; }); } catch (e) { this.setState({ saving: false, error: e.response?.body?.message || e.message }); } } async onHardDelete() { if (!this.state.client) return; this.setState({ saving: true, error: null, hardDeleteConfirmOpen: false }); try { await superagent .post(`/api/admin/oauth/clients/${this.state.client.uniqueId}/hard_delete`) .accept('json'); window.location.href = '/developers'; } catch (e) { this.setState({ saving: false, error: e.response?.body?.message || e.message }); } } handleRevealClose() { this.setState({ revealOpen: false }); if (this.state.afterRevealRedirect) { window.location.href = this.state.afterRevealRedirect; } } renderForm(readOnly) { const { scopeCatalog } = this.state; const scopeOptions = scopeCatalog.map(s => ({ key: s.name, text: s.name, value: s.name, description: s.requiredAccessPolicies.length > 0 ? `requires: ${s.requiredAccessPolicies.join(', ')}` : 'any authenticated user' })); return (
this.setState({ formName: d.value })} disabled={this.props.mode === 'edit' || readOnly} /> this.setState({ formDescription: d.value })} rows={2} disabled={readOnly} /> this.setState({ formLogoUrl: d.value })} disabled={readOnly} /> this.setState({ formHomepageUrl: d.value })} disabled={readOnly} /> OAuth configuration this.setState({ formRedirectUris: d.value })} rows={3} disabled={readOnly} />
One per line. Exact-match validated at /oauth/authorize time.
this.setState({ formSelectedScopes: d.value })} disabled={readOnly} /> Network this.setState({ formServerIps: d.value })} rows={2} disabled={readOnly} />
One per line. These IPs must be added to the admin_api EC2 security group manually before the client can call admin_api.
); } renderReadOnlyBanner() { return ( Read-only view You are not the owner of this client. Only the owner ({this.state.client.ownerAccountId}) or a SuperUser can modify it. Ownership transfer is DB-only — ask ops if the owner has left. ); } renderDetailsTab() { const { client } = this.state; const readOnly = !client?.canManage; const disabled = client?.disabledAt; const deleted = client?.deletedAt; return ( {readOnly && this.renderReadOnlyBanner()} {deleted && ( This client is soft-deleted

Deleted at {new Date(client.deletedAt).toLocaleString()}. It is invisible to OAuth runtime flows. Only a SuperUser can hard-delete or restore it (restore is DB-only).

)} {disabled && !deleted && ( This client is disabled

Reason: {client.disabledReason || '—'} · since {new Date(client.disabledAt).toLocaleString()}

)} {this.state.successMessage && {this.state.successMessage}} {this.state.error && {this.state.error}} {this.renderForm(readOnly)} {!readOnly && ( )}
Client ID
{client.clientId}
Owner
{client.ownerAccountId}
{!readOnly && (
Client Secret

The plaintext is never stored. Rotate to get a new one.

)} {!readOnly && !deleted && (
Status
{disabled ? ( ) : ( )}
)} {!readOnly && !deleted && (
Danger zone

Soft-delete: the row stays in the DB for audit. A SuperUser can hard-delete later.

)} {client.viewerIsSuperUser && deleted && (
SuperUser

Removes the row and cascades all grants. Cannot be undone.

)}
this.setState({ disableModalOpen: false })} > Disable {client?.name}?
this.setState({ disableReason: d.value })} />
this.setState({ deleteConfirmOpen: false })} onConfirm={() => this.onDelete()} confirmButton="Delete" /> this.setState({ hardDeleteConfirmOpen: false })} onConfirm={() => this.onHardDelete()} confirmButton="Hard-delete permanently" /> this.setState({ stepUpOpen: false, pendingAction: null, saving: false })} onVerified={() => this.onStepUpVerified()} />
); } renderCreate() { return (
Register new OAuth client Confidential client · v1
{this.state.error && {this.state.error}} {this.renderForm(false)} this.handleRevealClose()} clientId={this.state.revealClientId} plaintextSecret={this.state.revealPlaintext} title={this.state.revealTitle} />
); } renderEdit() { if (this.state.loading) { return ( Loading client… ); } if (!this.state.client) { return ( Client not found

{this.state.error}

); } const client = this.state.client; const panes = [ { menuItem: 'Details', render: () => {this.renderDetailsTab()} }, { menuItem: 'Grants', render: () => }, { menuItem: 'Audit log', render: () => } ]; return (
{client.name} {client.clientId} {client.disabledAt && } {client.deletedAt && } {!client.canManage && }
this.handleRevealClose()} clientId={this.state.revealClientId} plaintextSecret={this.state.revealPlaintext} title={this.state.revealTitle} />
); } render() { return this.props.mode === 'create' ? this.renderCreate() : this.renderEdit(); } }