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.state.client.ownerAccountId}) or a SuperUser
can modify it. Ownership transfer is DB-only — ask ops if the
owner has left.
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).
Reason: {client.disabledReason || '—'} · since {new Date(client.disabledAt).toLocaleString()}
The plaintext is never stored. Rotate to get a new one.
Soft-delete: the row stays in the DB for audit. A SuperUser can hard-delete later.
Removes the row and cascades all grants. Cannot be undone.
{this.state.error}
{client.clientId}
{client.disabledAt && }
{client.deletedAt && }
{!client.canManage && }