diff --git a/composer.lock b/composer.lock index 359b9f5..bcd72dc 100644 --- a/composer.lock +++ b/composer.lock @@ -3276,16 +3276,16 @@ }, { "name": "padosoft/laravel-iam-server", - "version": "v1.9.0", + "version": "v1.10.0", "source": { "type": "git", "url": "https://github.com/padosoft/laravel-iam-server.git", - "reference": "66dddbe5ad6ebe5f122dcc4f6bad81526b9d402c" + "reference": "25dd0027fd54cc5d90399c4ca64fb08bfa858cdd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/padosoft/laravel-iam-server/zipball/66dddbe5ad6ebe5f122dcc4f6bad81526b9d402c", - "reference": "66dddbe5ad6ebe5f122dcc4f6bad81526b9d402c", + "url": "https://api.github.com/repos/padosoft/laravel-iam-server/zipball/25dd0027fd54cc5d90399c4ca64fb08bfa858cdd", + "reference": "25dd0027fd54cc5d90399c4ca64fb08bfa858cdd", "shasum": "" }, "require": { @@ -3336,9 +3336,9 @@ "description": "Server Laravel IAM: identity, organizations, Application Registry + manifest, PDP (RBAC+ABAC+ReBAC), OAuth (league/oauth2-server) + OIDC layer, audit tamper-evident, governance/IGA, Admin API + panel.", "support": { "issues": "https://github.com/padosoft/laravel-iam-server/issues", - "source": "https://github.com/padosoft/laravel-iam-server/tree/v1.9.0" + "source": "https://github.com/padosoft/laravel-iam-server/tree/v1.10.0" }, - "time": "2026-07-03T18:17:13+00:00" + "time": "2026-07-03T19:23:39+00:00" }, { "name": "paragonie/constant_time_encoding", diff --git a/database/seeders/IamRolesSeeder.php b/database/seeders/IamRolesSeeder.php index c83998c..87944c8 100644 --- a/database/seeders/IamRolesSeeder.php +++ b/database/seeders/IamRolesSeeder.php @@ -28,7 +28,7 @@ class IamRolesSeeder extends Seeder */ private const PERMISSIONS = [ 'access_request.review', 'access_request.use', 'access_review.manage', 'applications.read', - 'audit.read', 'decisions.check', 'decisions.explain', 'directory.manage', 'directory.read', + 'audit.read', 'clients.manage', 'decisions.check', 'decisions.explain', 'directory.manage', 'directory.read', 'federated.manage', 'federated.read', 'grants.manage', 'groups.manage', 'groups.read', 'least_privilege.view', 'manifests.apply', 'manifests.approve', 'manifests.read', 'manifests.submit', 'metrics.read', 'organizations.manage', 'organizations.read', 'policies.read', diff --git a/resources/console/src/pages/Applications.tsx b/resources/console/src/pages/Applications.tsx index d99c3e7..34e04b2 100644 --- a/resources/console/src/pages/Applications.tsx +++ b/resources/console/src/pages/Applications.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { apiGet, apiPost, errorMessage } from '../lib/api' import { useCursorList, useResource } from '../hooks/useApi' -import { asText, pick } from '../lib/format' +import { asText, formatDate, pick } from '../lib/format' import PageHeader from '../components/PageHeader' import { useToast } from '../components/toast-context' import { Badge, Button, Card, EmptyState, ErrorState, Field, KeyValues, Loading, Modal, Table, Td, Th } from '../components/ui' @@ -210,6 +210,100 @@ function Credential({ label, value, onCopy, mono }: { label: string; value: stri ) } +function statusTone(s: string): 'ok' | 'warn' | 'danger' | 'neutral' { + if (s === 'ok') return 'ok' + if (s === 'expiring') return 'warn' + if (s === 'expired' || s === 'revoked') return 'danger' + return 'neutral' +} + +function ClientCredentials({ appKey }: { appKey: string }) { + const toast = useToast() + const [info, setInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [failed, setFailed] = useState(false) + const [busy, setBusy] = useState(false) + const [newSecret, setNewSecret] = useState(null) + + async function load() { + setLoading(true) + try { + setInfo(await apiGet(`applications/${encodeURIComponent(appKey)}/client`)) + } catch { + setFailed(true) // app may have no OAuth client (public / not yet applied) → hide the section + } finally { + setLoading(false) + } + } + useEffect(() => { + void load() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appKey]) + + async function rotate() { + setBusy(true) + try { + const res = await apiPost(`applications/${encodeURIComponent(appKey)}/rotate-secret`) + setNewSecret(asText(pick(res, ['client_secret']))) + toast.success('Secret rotated — the old one stays valid during the grace window.') + void load() + } catch (e) { + toast.error(errorMessage(e)) + } finally { + setBusy(false) + } + } + + async function revoke() { + setBusy(true) + try { + await apiPost(`applications/${encodeURIComponent(appKey)}/revoke-client`) + toast.success('Client revoked.') + void load() + } catch (e) { + toast.error(errorMessage(e)) + } finally { + setBusy(false) + } + } + + if (loading || failed || !info) { + return null + } + + const status = asText(pick(info, ['secret_status'])) + const revoked = status === 'revoked' + const isPublic = status === 'public' + + return ( +
+

OAuth client

+
+
+ {asText(pick(info, ['client_id']))} + {status} + {pick(info, ['grace_active']) === true && grace until {formatDate(pick(info, ['grace_until']))}} +
+ {asText(pick(info, ['secret_expires_at'])) !== '—' &&

Secret expires {formatDate(pick(info, ['secret_expires_at']))}

} + + {newSecret && ( +
+ { void navigator.clipboard?.writeText(v); toast.success('Copied.') }} mono /> +
Deploy this to the app during the grace window — shown once. The previous secret keeps working until the grace ends, so there's no downtime.
+
+ )} + + {!isPublic && !revoked && ( +
+ + +
+ )} +
+
+ ) +} + function ApplicationDetail({ app, onClose }: { app: Row; onClose: () => void }) { const id = appId(app) const manifest = useResource(() => apiGet(`applications/${encodeURIComponent(id)}/manifest`), [id]) @@ -221,6 +315,7 @@ function ApplicationDetail({ app, onClose }: { app: Row; onClose: () => void })

Application

+

Applied manifest

diff --git a/tests/e2e/console.spec.ts b/tests/e2e/console.spec.ts index 08d63f8..398dca4 100644 --- a/tests/e2e/console.spec.ts +++ b/tests/e2e/console.spec.ts @@ -204,4 +204,15 @@ test('login → every screen → create user → assign a permission', async ({ ]) await page.getByRole('button', { name: 'Review' }).first().click() await expect(page.getByText('Super Admin')).toBeVisible() + await page.locator('[role="dialog"]').getByRole('button', { name: 'Close' }).click() // close the review modal + + // 7) The onboarded app's OAuth client secret can be rotated with zero downtime (new secret shown once). + await page.getByRole('link', { name: 'Applications', exact: true }).click() + await page.locator('tr', { hasText: appKey }).getByRole('button', { name: 'Details' }).click() + await expect(page.getByRole('button', { name: 'Rotate secret' })).toBeVisible() + await Promise.all([ + page.waitForResponse((x) => /\/rotate-secret$/.test(x.url()) && x.request().method() === 'POST'), + page.getByRole('button', { name: 'Rotate secret' }).click(), + ]) + await expect(page.getByText('new client_secret')).toBeVisible() })