mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:21:32 +00:00
feat(gui+auth): break-glass admin GUI surface (CRIT-4 closure)
Closes CRIT-4 of the 2026-05-10 audit. Bundle 2 Phase 7.5 shipped the
break-glass backend (Argon2id + lockout + 4 endpoints) but no GUI
surface. Operators recovering during an SSO outage had to hand-craft
curl commands — operationally hostile and the opposite of what
docs/operator/security.md advertised. This commit closes the gap.
Three GUI surfaces:
1. LoginPage.tsx — inline "Use break-glass account (SSO outage
recovery)" toggle below the API-key form. Clicking reveals an
amber-bordered inline form (actor-id + password, autocomplete=off).
Calls breakglassLogin(actor_id, password); on success navigates
to "/" where AuthProvider re-validates via the session-cookie path.
Intentionally low-visibility (text-amber-600 small text) — this is
the deliberate-bypass path, not the everyday-login path.
2. web/src/pages/auth/BreakglassPage.tsx — admin page at /auth/breakglass
(permission-gated by auth.breakglass.admin). Three sections:
- Sticky security banner ("every action audited; use only during
incidents").
- Set/rotate-password form (≥12-char + confirm-match).
- Credentialed-actor table with rotate / unlock (disabled when
not locked) / remove per row. Remove requires type-the-actor-id
confirmation.
3. Layout.tsx nav — "Break-glass" entry under the auth section. Visible
to all callers; the page itself permission-gates (server-side 403 is
the load-bearing defense). Cosmetic hide-when-no-perm is deferred
to fix 14's LOW bundle.
Backend support (new endpoint required to enumerate credentialed actors):
- internal/repository/breakglass.go — BreakglassCredentialRepository
gains List(ctx, tenantID) method.
- internal/repository/postgres/breakglass.go — postgres impl; reuses
the existing breakglassColumns / scanBreakglass helpers.
- internal/auth/breakglass/service.go — Service.List(ctx) method;
returns ErrDisabled when CERTCTL_BREAKGLASS_ENABLED=false (handler
maps to 404 for surface invisibility).
- internal/api/handler/auth_breakglass.go — ListCredentials handler;
password_hash field NEVER serialized to the wire (response shape
is intentionally limited to actor_id + timestamps + failure_count +
locked_until).
- internal/api/router/router.go — registers GET
/api/v1/auth/breakglass/credentials gated by auth.breakglass.admin.
- internal/api/router/openapi_parity_test.go — SpecParityExceptions
entry for the new endpoint (full OpenAPI row rides along with the
next OpenAPI sweep).
GUI api/client.ts gains breakglassListCredentials() + the
BreakglassCredentialRow type matching the wire shape.
Six Vitest cases in BreakglassPage.test.tsx pin the contract:
permission gate (forbidden state when caller lacks the perm; admin
surface when they have it), set-password mismatch rejection, set-
password below-threshold-length rejection, unlock-disabled-when-not-
locked, remove-modal type-confirm.
Verification gate green:
- gofmt -l clean on all touched files
- go vet clean
- go test -short -count=1 on internal/api/router (TestRouter_OpenAPIParity
+ TestRouterRBACGateCoverage + TestRouter_AuthExemptAllowlist),
internal/api/handler (all BCL tests + ListCredentials),
internal/auth/breakglass (Service.List + stubRepo.List),
internal/repository/postgres, internal/domain/auth (auditor pin)
— all pass.
CRIT-1 + CRIT-2 + CRIT-3 from the same audit are already closed on
this branch (commits 68ca42f, ca1e135, 00eace8). CRIT-5 (AllowedEmail-
Domains lying field) remains the last Critical blocker for v2.1.0.
Spec: cowork/auth-bundles-fixes-2026-05-10/04-crit-4-breakglass-gui.md.
Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-4
This commit is contained in:
@@ -30,6 +30,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth/breakglass"
|
||||
bgdomain "github.com/certctl-io/certctl/internal/auth/breakglass/domain"
|
||||
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
|
||||
)
|
||||
|
||||
@@ -46,6 +47,7 @@ type BreakglassService interface {
|
||||
Authenticate(ctx context.Context, actorID, plaintext, ip, userAgent string) (*breakglass.AuthenticateResult, error)
|
||||
Unlock(ctx context.Context, callerActorID, targetActorID string) error
|
||||
RemoveCredential(ctx context.Context, callerActorID, targetActorID string) error
|
||||
List(ctx context.Context) ([]*bgdomain.BreakglassCredential, error)
|
||||
}
|
||||
|
||||
// AuthBreakglassHandler ships the Phase 7.5 surface.
|
||||
@@ -254,3 +256,62 @@ func (h *AuthBreakglassHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// breakglassCredentialResponse is the wire shape returned by ListCredentials.
|
||||
// Intentionally omits PasswordHash — the admin GUI only needs metadata to
|
||||
// render the credentialed-actor table.
|
||||
type breakglassCredentialResponse struct {
|
||||
ActorID string `json:"actor_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastPasswordChangeAt string `json:"last_password_change_at"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
LockedUntil *string `json:"locked_until,omitempty"`
|
||||
LastFailureAt *string `json:"last_failure_at,omitempty"`
|
||||
}
|
||||
|
||||
type listBreakglassCredentialsResponse struct {
|
||||
Credentials []breakglassCredentialResponse `json:"credentials"`
|
||||
}
|
||||
|
||||
// ListCredentials handles GET /api/v1/auth/breakglass/credentials.
|
||||
// Permission: auth.breakglass.admin.
|
||||
//
|
||||
// Audit 2026-05-10 CRIT-4 closure — backs the admin GUI Break-glass
|
||||
// page. Returns 404 when CERTCTL_BREAKGLASS_ENABLED=false (surface
|
||||
// invisibility, consistent with the other break-glass admin endpoints).
|
||||
// The password hash is NEVER serialized to the wire.
|
||||
func (h *AuthBreakglassHandler) ListCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
if h.svc == nil || !h.svc.Enabled() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
creds, err := h.svc.List(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, breakglass.ErrDisabled) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "could not list break-glass credentials")
|
||||
return
|
||||
}
|
||||
resp := listBreakglassCredentialsResponse{Credentials: make([]breakglassCredentialResponse, 0, len(creds))}
|
||||
for _, c := range creds {
|
||||
row := breakglassCredentialResponse{
|
||||
ActorID: c.ActorID,
|
||||
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
|
||||
LastPasswordChangeAt: c.LastPasswordChangeAt.UTC().Format(time.RFC3339),
|
||||
FailureCount: c.FailureCount,
|
||||
}
|
||||
if c.LockedUntil != nil {
|
||||
s := c.LockedUntil.UTC().Format(time.RFC3339)
|
||||
row.LockedUntil = &s
|
||||
}
|
||||
if c.LastFailureAt != nil {
|
||||
s := c.LastFailureAt.UTC().Format(time.RFC3339)
|
||||
row.LastFailureAt = &s
|
||||
}
|
||||
resp.Credentials = append(resp.Credentials, row)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ var SpecParityExceptions = map[string]string{
|
||||
// extension). Full per-endpoint OpenAPI rows ride along with that
|
||||
// commit; until then the surface is tracked here.
|
||||
"POST /auth/breakglass/login": "Auth Bundle 2 Phase 7.5 — local-password login; auth-exempt; 404 when disabled (surface invisibility per spec).",
|
||||
"GET /api/v1/auth/breakglass/credentials": "Audit 2026-05-10 CRIT-4 — list credentialed actors (metadata only; no password hash on the wire); gated auth.breakglass.admin.",
|
||||
"POST /api/v1/auth/breakglass/credentials": "Auth Bundle 2 Phase 7.5 — set/rotate password; gated auth.breakglass.admin.",
|
||||
"POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock": "Auth Bundle 2 Phase 7.5 — clear lockout state; gated auth.breakglass.admin.",
|
||||
"DELETE /api/v1/auth/breakglass/credentials/{actor_id}": "Auth Bundle 2 Phase 7.5 — remove credential; gated auth.breakglass.admin.",
|
||||
|
||||
@@ -476,6 +476,7 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
http.HandlerFunc(reg.AuthBreakglass.Login),
|
||||
middleware.NewCORS(reg.CorsCfg), middleware.ContentType,
|
||||
))
|
||||
r.Register("GET /api/v1/auth/breakglass/credentials", rbacGate(reg.Checker, "auth.breakglass.admin", reg.AuthBreakglass.ListCredentials))
|
||||
r.Register("POST /api/v1/auth/breakglass/credentials", rbacGate(reg.Checker, "auth.breakglass.admin", reg.AuthBreakglass.SetPassword))
|
||||
r.Register("POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock", rbacGate(reg.Checker, "auth.breakglass.admin", reg.AuthBreakglass.Unlock))
|
||||
r.Register("DELETE /api/v1/auth/breakglass/credentials/{actor_id}", rbacGate(reg.Checker, "auth.breakglass.admin", reg.AuthBreakglass.Remove))
|
||||
|
||||
@@ -408,6 +408,25 @@ func (s *Service) RemoveCredential(ctx context.Context, callerActorID, targetAct
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns the metadata for every break-glass credential in the
|
||||
// tenant. Audit 2026-05-10 CRIT-4 closure — backs the GUI admin page
|
||||
// that enumerates credentialed actors. Returns ErrDisabled when the
|
||||
// service is off (callers map to 404 for surface invisibility).
|
||||
//
|
||||
// The returned rows DO include the password_hash field (the service
|
||||
// boundary is the repo; the handler is responsible for stripping the
|
||||
// hash from the wire response).
|
||||
func (s *Service) List(ctx context.Context) ([]*bgdomain.BreakglassCredential, error) {
|
||||
if !s.Enabled() {
|
||||
return nil, ErrDisabled
|
||||
}
|
||||
out, err := s.repo.List(ctx, s.tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("breakglass: list: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers — Argon2id hash + verify, ID generation, audit, dummy verify.
|
||||
// =============================================================================
|
||||
|
||||
@@ -112,6 +112,16 @@ func (s *stubRepo) Delete(_ context.Context, actorID, _ string) error {
|
||||
delete(s.rows, actorID)
|
||||
return nil
|
||||
}
|
||||
func (s *stubRepo) List(_ context.Context, _ string) ([]*bgdomain.BreakglassCredential, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]*bgdomain.BreakglassCredential, 0, len(s.rows))
|
||||
for _, c := range s.rows {
|
||||
cp := *c
|
||||
out = append(out, &cp)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type stubAudit struct {
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -59,4 +59,10 @@ type BreakglassCredentialRepository interface {
|
||||
// (separate concern; the operator can call SessionService.RevokeAll
|
||||
// in lockstep).
|
||||
Delete(ctx context.Context, actorID, tenantID string) error
|
||||
|
||||
// List returns the metadata for every break-glass credential in the
|
||||
// tenant. The password hash is NOT included in the returned rows —
|
||||
// the admin GUI uses this to render the credentialed-actor table
|
||||
// (audit 2026-05-10 CRIT-4 closure). Order: created_at ASC.
|
||||
List(ctx context.Context, tenantID string) ([]*bgdomain.BreakglassCredential, error)
|
||||
}
|
||||
|
||||
@@ -164,3 +164,33 @@ func (r *BreakglassCredentialRepository) Delete(ctx context.Context, actorID, te
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every break-glass credential in the tenant. Audit
|
||||
// 2026-05-10 CRIT-4 closure — backs the GUI admin page that lists
|
||||
// credentialed actors. The password hash is read into the returned
|
||||
// row (it's an internal type passed to the handler which strips it
|
||||
// before serializing the JSON response).
|
||||
func (r *BreakglassCredentialRepository) List(ctx context.Context, tenantID string) ([]*bgdomain.BreakglassCredential, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+breakglassColumns+`
|
||||
FROM breakglass_credentials
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("breakglass list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*bgdomain.BreakglassCredential
|
||||
for rows.Next() {
|
||||
c, err := scanBreakglass(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("breakglass list scan: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("breakglass list iter: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -453,6 +453,23 @@ export const breakglassRemove = (targetActorID: string) =>
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
export type BreakglassCredentialRow = {
|
||||
actor_id: string;
|
||||
created_at: string;
|
||||
last_password_change_at: string;
|
||||
failure_count: number;
|
||||
locked_until?: string;
|
||||
last_failure_at?: string;
|
||||
};
|
||||
|
||||
// Audit 2026-05-10 CRIT-4 closure — admin GUI Break-glass page. The
|
||||
// password hash is never returned by the server; this lists only the
|
||||
// metadata operators need to render the credentialed-actor table.
|
||||
// Returns 404 when CERTCTL_BREAKGLASS_ENABLED=false (surface invisibility).
|
||||
export const breakglassListCredentials = () =>
|
||||
fetchJSON<{ credentials: BreakglassCredentialRow[] }>(`${BASE}/auth/breakglass/credentials`)
|
||||
.then(r => r.credentials);
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 10 — approvals queue.
|
||||
//
|
||||
|
||||
@@ -33,6 +33,8 @@ const nav = [
|
||||
{ to: '/auth/roles', label: 'Roles', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
|
||||
{ to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
||||
{ to: '/auth/approvals', label: 'Approvals', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
// Audit 2026-05-10 CRIT-4 closure — break-glass admin surface.
|
||||
{ to: '/auth/breakglass', label: 'Break-glass', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
|
||||
{ to: '/auth/settings', label: 'Auth Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
];
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import OIDCProvidersPage from './pages/auth/OIDCProvidersPage';
|
||||
import OIDCProviderDetailPage from './pages/auth/OIDCProviderDetailPage';
|
||||
import GroupMappingsPage from './pages/auth/GroupMappingsPage';
|
||||
import SessionsPage from './pages/auth/SessionsPage';
|
||||
import BreakglassPage from './pages/auth/BreakglassPage';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -132,6 +133,8 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="auth/keys" element={<KeysPage />} />
|
||||
<Route path="auth/settings" element={<AuthSettingsPage />} />
|
||||
<Route path="auth/approvals" element={<ApprovalsPage />} />
|
||||
{/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */}
|
||||
<Route path="auth/breakglass" element={<BreakglassPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
+120
-2
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../components/AuthProvider';
|
||||
import { getAuthInfo, type AuthInfoOIDCProvider } from '../api/client';
|
||||
import { getAuthInfo, breakglassLogin, type AuthInfoOIDCProvider } from '../api/client';
|
||||
|
||||
// =============================================================================
|
||||
// LoginPage — Bundle 2 Phase 8 / multi-mode entry surface.
|
||||
@@ -10,16 +11,30 @@ import { getAuthInfo, type AuthInfoOIDCProvider } from '../api/client';
|
||||
// page renders one "Sign in with X" button per provider; clicking
|
||||
// navigates to the provider's `login_url` (which 302s through the
|
||||
// IdP and back to /auth/oidc/callback). The API-key form remains as
|
||||
// a fallback for Bearer-mode deployments + the break-glass path.
|
||||
// a fallback for Bearer-mode deployments.
|
||||
//
|
||||
// Audit 2026-05-10 CRIT-4 closure: an inline break-glass form below
|
||||
// the API-key form lets admins recover during SSO incidents without
|
||||
// crafting curl commands. The link is intentionally low-key
|
||||
// (text-amber-600 small text) — break-glass is the deliberate-bypass
|
||||
// path, not the everyday-login path.
|
||||
// =============================================================================
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, error: authError } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [key, setKey] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [providers, setProviders] = useState<AuthInfoOIDCProvider[]>([]);
|
||||
|
||||
// Break-glass inline form state.
|
||||
const [showBreakglass, setShowBreakglass] = useState(false);
|
||||
const [bgActorID, setBgActorID] = useState('');
|
||||
const [bgPassword, setBgPassword] = useState('');
|
||||
const [bgError, setBgError] = useState<string | null>(null);
|
||||
const [bgSubmitting, setBgSubmitting] = useState(false);
|
||||
|
||||
const error = localError || authError;
|
||||
|
||||
// On mount, fetch /auth/info and extract any configured OIDC
|
||||
@@ -51,6 +66,24 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBreakglassSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!bgActorID.trim() || !bgPassword) return;
|
||||
setBgSubmitting(true);
|
||||
setBgError(null);
|
||||
try {
|
||||
await breakglassLogin(bgActorID.trim(), bgPassword);
|
||||
// breakglassLogin sets the session cookie via Set-Cookie; navigate
|
||||
// to the dashboard, which the AuthProvider will re-validate via
|
||||
// its session-cookie path on next render.
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setBgError(err instanceof Error ? err.message : 'Break-glass login failed.');
|
||||
} finally {
|
||||
setBgSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-page flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
@@ -126,6 +159,91 @@ export default function LoginPage() {
|
||||
The API key is set via <code className="text-ink-faint bg-page px-1 py-0.5 rounded">CERTCTL_AUTH_SECRET</code> on the server.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{/* Break-glass entry — low-visibility on purpose. CRIT-4 closure. */}
|
||||
<div className="mt-4 text-center" data-testid="login-breakglass-entry">
|
||||
{!showBreakglass ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBreakglass(true)}
|
||||
className="text-xs text-amber-600 hover:text-amber-700 hover:underline"
|
||||
data-testid="login-breakglass-toggle"
|
||||
>
|
||||
Use break-glass account (SSO outage recovery)
|
||||
</button>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleBreakglassSubmit}
|
||||
className="bg-amber-50 border border-amber-200 rounded p-4 mt-4 space-y-3 text-left"
|
||||
data-testid="login-breakglass-form"
|
||||
>
|
||||
<p className="text-xs font-medium text-amber-900">
|
||||
Break-glass admin login — every action is audited. Use only during SSO incidents.
|
||||
</p>
|
||||
<div>
|
||||
<label htmlFor="bg-actor-id" className="block text-xs font-medium text-amber-900 mb-1">
|
||||
Actor ID
|
||||
</label>
|
||||
<input
|
||||
id="bg-actor-id"
|
||||
type="text"
|
||||
value={bgActorID}
|
||||
onChange={e => setBgActorID(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="actor-..."
|
||||
className="w-full bg-white border border-amber-300 rounded px-3 py-2 text-sm text-ink placeholder-ink-faint focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500/20"
|
||||
data-testid="login-breakglass-actor-id"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="bg-password" className="block text-xs font-medium text-amber-900 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="bg-password"
|
||||
type="password"
|
||||
value={bgPassword}
|
||||
onChange={e => setBgPassword(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="w-full bg-white border border-amber-300 rounded px-3 py-2 text-sm text-ink placeholder-ink-faint focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500/20"
|
||||
data-testid="login-breakglass-password"
|
||||
/>
|
||||
</div>
|
||||
{bgError && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200 rounded px-3 py-2 text-xs text-red-700"
|
||||
data-testid="login-breakglass-error"
|
||||
>
|
||||
{bgError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={bgSubmitting || !bgActorID.trim() || !bgPassword}
|
||||
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white py-2 text-sm font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-testid="login-breakglass-submit"
|
||||
>
|
||||
{bgSubmitting ? 'Signing in…' : 'Sign in (break-glass)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowBreakglass(false);
|
||||
setBgActorID('');
|
||||
setBgPassword('');
|
||||
setBgError(null);
|
||||
}}
|
||||
className="px-3 py-2 text-sm font-medium text-amber-900 hover:bg-amber-100 rounded transition-colors"
|
||||
data-testid="login-breakglass-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Audit 2026-05-10 CRIT-4 closure — BreakglassPage tests. Pins:
|
||||
// - Forbidden page when caller lacks auth.breakglass.admin.
|
||||
// - Renders credential rows from the API when caller has permission.
|
||||
// - Set-password form rejects mismatched passwords.
|
||||
// - Set-password form rejects below-threshold length.
|
||||
// - Unlock button disabled when actor is not locked.
|
||||
// - Remove modal requires actor-id type-confirmation.
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
breakglassListCredentials: vi.fn(),
|
||||
breakglassSetPassword: vi.fn(),
|
||||
breakglassUnlock: vi.fn(),
|
||||
breakglassRemove: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useAuthMe', () => ({
|
||||
useAuthMe: vi.fn(),
|
||||
}));
|
||||
|
||||
import BreakglassPage from './BreakglassPage';
|
||||
import * as client from '../../api/client';
|
||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||
|
||||
function renderWithProviders(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function mockMe(opts: { hasPerm: boolean }) {
|
||||
(useAuthMe as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: { actor_id: 'admin', permissions: opts.hasPerm ? ['auth.breakglass.admin'] : [] },
|
||||
hasPerm: (p: string) => opts.hasPerm && p === 'auth.breakglass.admin',
|
||||
});
|
||||
}
|
||||
|
||||
describe('BreakglassPage permission gating', () => {
|
||||
it('renders the forbidden state when caller lacks auth.breakglass.admin', () => {
|
||||
mockMe({ hasPerm: false });
|
||||
renderWithProviders(<BreakglassPage />);
|
||||
expect(screen.getByText(/Forbidden/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('breakglass-new-form')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the admin surface when caller has auth.breakglass.admin', async () => {
|
||||
mockMe({ hasPerm: true });
|
||||
(client.breakglassListCredentials as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
actor_id: 'admin',
|
||||
created_at: '2026-05-10T00:00:00Z',
|
||||
last_password_change_at: '2026-05-10T00:00:00Z',
|
||||
failure_count: 0,
|
||||
},
|
||||
]);
|
||||
renderWithProviders(<BreakglassPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('breakglass-row-admin')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('breakglass-new-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BreakglassPage set-password validation', () => {
|
||||
beforeEach(() => {
|
||||
mockMe({ hasPerm: true });
|
||||
(client.breakglassListCredentials as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('rejects mismatched passwords', async () => {
|
||||
renderWithProviders(<BreakglassPage />);
|
||||
fireEvent.change(screen.getByTestId('breakglass-new-actor-id'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByTestId('breakglass-new-password'), {
|
||||
target: { value: 'pass-long-enough-12' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('breakglass-new-password-confirm'), {
|
||||
target: { value: 'pass-different-yo-12' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('breakglass-new-submit'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('breakglass-new-error')).toHaveTextContent(/match/i);
|
||||
});
|
||||
expect(client.breakglassSetPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects below-threshold password length', async () => {
|
||||
renderWithProviders(<BreakglassPage />);
|
||||
fireEvent.change(screen.getByTestId('breakglass-new-actor-id'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByTestId('breakglass-new-password'), { target: { value: 'short' } });
|
||||
fireEvent.change(screen.getByTestId('breakglass-new-password-confirm'), {
|
||||
target: { value: 'short' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('breakglass-new-submit'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('breakglass-new-error')).toHaveTextContent(/12 characters/i);
|
||||
});
|
||||
expect(client.breakglassSetPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BreakglassPage credential actions', () => {
|
||||
beforeEach(() => {
|
||||
mockMe({ hasPerm: true });
|
||||
});
|
||||
|
||||
it('disables unlock button when actor is not locked', async () => {
|
||||
(client.breakglassListCredentials as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
actor_id: 'alice',
|
||||
created_at: '2026-05-10T00:00:00Z',
|
||||
last_password_change_at: '2026-05-10T00:00:00Z',
|
||||
failure_count: 0,
|
||||
},
|
||||
]);
|
||||
renderWithProviders(<BreakglassPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('breakglass-row-alice')).toBeInTheDocument();
|
||||
});
|
||||
const unlockBtn = screen.getByTestId('breakglass-unlock-alice');
|
||||
expect(unlockBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('remove modal requires actor-id type-confirmation', async () => {
|
||||
(client.breakglassListCredentials as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
actor_id: 'alice',
|
||||
created_at: '2026-05-10T00:00:00Z',
|
||||
last_password_change_at: '2026-05-10T00:00:00Z',
|
||||
failure_count: 0,
|
||||
},
|
||||
]);
|
||||
renderWithProviders(<BreakglassPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('breakglass-row-alice')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('breakglass-remove-alice'));
|
||||
const removeBtn = screen.getByTestId('breakglass-remove-confirm-submit');
|
||||
expect(removeBtn).toBeDisabled();
|
||||
|
||||
// Typing the wrong actor-id keeps it disabled.
|
||||
fireEvent.change(screen.getByTestId('breakglass-remove-confirm-input'), {
|
||||
target: { value: 'bob' },
|
||||
});
|
||||
expect(removeBtn).toBeDisabled();
|
||||
|
||||
// Typing the correct actor-id enables it.
|
||||
fireEvent.change(screen.getByTestId('breakglass-remove-confirm-input'), {
|
||||
target: { value: 'alice' },
|
||||
});
|
||||
expect(removeBtn).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(removeBtn);
|
||||
await waitFor(() => {
|
||||
expect(client.breakglassRemove).toHaveBeenCalledWith('alice');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
breakglassListCredentials,
|
||||
breakglassSetPassword,
|
||||
breakglassUnlock,
|
||||
breakglassRemove,
|
||||
type BreakglassCredentialRow,
|
||||
} from '../../api/client';
|
||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
|
||||
// =============================================================================
|
||||
// BreakglassPage — Audit 2026-05-10 CRIT-4 closure.
|
||||
//
|
||||
// Admin GUI for the break-glass admin path. Lists credentialed actors,
|
||||
// supports password rotation, unlock, and credential removal. Every
|
||||
// action is auditing-heavy by design — break-glass is the deliberate
|
||||
// SSO-bypass path, intended for use during SSO incidents only.
|
||||
//
|
||||
// Route: /auth/breakglass
|
||||
// Permission: auth.breakglass.admin
|
||||
//
|
||||
// Backend:
|
||||
// GET /api/v1/auth/breakglass/credentials (list)
|
||||
// POST /api/v1/auth/breakglass/credentials (set/rotate password)
|
||||
// POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock (unlock after lockout)
|
||||
// DELETE /api/v1/auth/breakglass/credentials/{actor_id} (remove credential)
|
||||
//
|
||||
// Surface invisibility: every backend endpoint returns 404 when
|
||||
// CERTCTL_BREAKGLASS_ENABLED=false; the page renders a "disabled"
|
||||
// banner in that case (the list query 404s and we treat that as the
|
||||
// disabled-on-server signal).
|
||||
// =============================================================================
|
||||
|
||||
export default function BreakglassPage() {
|
||||
const { isLoading: meLoading, hasPerm } = useAuthMe();
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Permission gate. If meLoading, render nothing (avoid flicker).
|
||||
const canAdmin = hasPerm('auth.breakglass.admin');
|
||||
|
||||
const {
|
||||
data: rows,
|
||||
isLoading,
|
||||
error: loadErr,
|
||||
} = useQuery({
|
||||
queryKey: ['breakglass', 'credentials'],
|
||||
queryFn: () => breakglassListCredentials(),
|
||||
enabled: canAdmin,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const setPwd = useMutation({
|
||||
mutationFn: ({ actorID, password }: { actorID: string; password: string }) =>
|
||||
breakglassSetPassword(actorID, password),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['breakglass'] }),
|
||||
});
|
||||
const unlock = useMutation({
|
||||
mutationFn: (actorID: string) => breakglassUnlock(actorID),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['breakglass'] }),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (actorID: string) => breakglassRemove(actorID),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['breakglass'] }),
|
||||
});
|
||||
|
||||
// Modal state.
|
||||
const [pwdModalActorID, setPwdModalActorID] = useState<string | null>(null);
|
||||
const [removeModalActorID, setRemoveModalActorID] = useState<string | null>(null);
|
||||
// New-credential row form state (separate from rotation modal).
|
||||
const [newActorID, setNewActorID] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
|
||||
const [newFormError, setNewFormError] = useState<string | null>(null);
|
||||
|
||||
if (meLoading) return null;
|
||||
|
||||
if (!canAdmin) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader title="Break-glass" subtitle="Admin-only SSO-bypass recovery path" />
|
||||
<ErrorState
|
||||
title="Forbidden"
|
||||
message="You need auth.breakglass.admin to view this page."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 404 from the list endpoint == server has CERTCTL_BREAKGLASS_ENABLED=false.
|
||||
const disabledOnServer =
|
||||
loadErr instanceof Error && /not enabled|404|disabled/i.test(loadErr.message);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl">
|
||||
<PageHeader
|
||||
title="Break-glass admin"
|
||||
subtitle="SSO-bypass recovery path — every action audited. Use only during SSO incidents."
|
||||
/>
|
||||
|
||||
<div
|
||||
className="bg-amber-50 border border-amber-200 rounded p-4 mb-6 text-sm text-amber-900"
|
||||
data-testid="breakglass-banner"
|
||||
>
|
||||
<strong>Security note.</strong> Break-glass credentials bypass your IdP entirely. Set
|
||||
the password under <code className="bg-amber-100 px-1 rounded">CERTCTL_BREAKGLASS_ENABLED=true</code> only when SSO
|
||||
is broken; remove the credential once SSO recovers. Every action here is recorded in the audit log under the
|
||||
<code className="bg-amber-100 px-1 rounded">auth</code> category.
|
||||
</div>
|
||||
|
||||
{disabledOnServer && (
|
||||
<ErrorState
|
||||
title="Break-glass disabled on server"
|
||||
message="The server is running with CERTCTL_BREAKGLASS_ENABLED=false. Set it to true on the certctl-server process to enable this surface."
|
||||
data-testid="breakglass-disabled-banner"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!disabledOnServer && (
|
||||
<>
|
||||
{/* Create-new-credential form */}
|
||||
<section className="bg-surface border border-surface-border rounded p-6 mb-6">
|
||||
<h2 className="text-base font-semibold text-ink mb-3">Set or rotate password</h2>
|
||||
<form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
setNewFormError(null);
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
setNewFormError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 12) {
|
||||
setNewFormError('Password must be at least 12 characters.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setPwd.mutateAsync({ actorID: newActorID.trim(), password: newPassword });
|
||||
setNewActorID('');
|
||||
setNewPassword('');
|
||||
setNewPasswordConfirm('');
|
||||
} catch (err) {
|
||||
setNewFormError(err instanceof Error ? err.message : 'Could not set password.');
|
||||
}
|
||||
}}
|
||||
className="space-y-3"
|
||||
data-testid="breakglass-new-form"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-ink-muted mb-1">Actor ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newActorID}
|
||||
onChange={e => setNewActorID(e.target.value)}
|
||||
placeholder="actor-..."
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm focus:outline-none focus:border-brand-400"
|
||||
data-testid="breakglass-new-actor-id"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-ink-muted mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm focus:outline-none focus:border-brand-400"
|
||||
data-testid="breakglass-new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-ink-muted mb-1">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPasswordConfirm}
|
||||
onChange={e => setNewPasswordConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm focus:outline-none focus:border-brand-400"
|
||||
data-testid="breakglass-new-password-confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{newFormError && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200 rounded px-3 py-2 text-xs text-red-700"
|
||||
data-testid="breakglass-new-error"
|
||||
>
|
||||
{newFormError}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newActorID.trim() || !newPassword || setPwd.isPending}
|
||||
className="bg-brand-400 hover:bg-brand-500 text-white px-4 py-2 text-sm font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-testid="breakglass-new-submit"
|
||||
>
|
||||
{setPwd.isPending ? 'Setting…' : 'Set password'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Credential list */}
|
||||
<section className="bg-surface border border-surface-border rounded p-6">
|
||||
<h2 className="text-base font-semibold text-ink mb-3">Credentialed actors</h2>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-ink-muted">Loading…</p>
|
||||
) : !rows || rows.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">No break-glass credentials configured.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm" data-testid="breakglass-credentials-table">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border">
|
||||
<th className="text-left py-2 font-medium text-ink-muted">Actor</th>
|
||||
<th className="text-left py-2 font-medium text-ink-muted">Last password change</th>
|
||||
<th className="text-left py-2 font-medium text-ink-muted">Failures</th>
|
||||
<th className="text-left py-2 font-medium text-ink-muted">Locked until</th>
|
||||
<th className="text-right py-2 font-medium text-ink-muted">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row: BreakglassCredentialRow) => {
|
||||
const isLocked = row.locked_until && new Date(row.locked_until) > new Date();
|
||||
return (
|
||||
<tr
|
||||
key={row.actor_id}
|
||||
className="border-b border-surface-border last:border-0"
|
||||
data-testid={`breakglass-row-${row.actor_id}`}
|
||||
>
|
||||
<td className="py-3 font-mono text-xs">{row.actor_id}</td>
|
||||
<td className="py-3 text-xs text-ink-muted">
|
||||
{new Date(row.last_password_change_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-3 text-xs">
|
||||
{row.failure_count > 0 ? (
|
||||
<span className="text-red-700 font-medium">{row.failure_count}</span>
|
||||
) : (
|
||||
<span className="text-ink-muted">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 text-xs text-ink-muted">
|
||||
{isLocked ? (
|
||||
<span className="text-red-700">
|
||||
{new Date(row.locked_until!).toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => setPwdModalActorID(row.actor_id)}
|
||||
className="text-xs text-brand-400 hover:underline"
|
||||
data-testid={`breakglass-rotate-${row.actor_id}`}
|
||||
>
|
||||
Rotate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => unlock.mutate(row.actor_id)}
|
||||
disabled={!isLocked || unlock.isPending}
|
||||
className="text-xs text-amber-700 hover:underline disabled:opacity-30 disabled:no-underline disabled:cursor-not-allowed"
|
||||
data-testid={`breakglass-unlock-${row.actor_id}`}
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRemoveModalActorID(row.actor_id)}
|
||||
className="text-xs text-red-700 hover:underline"
|
||||
data-testid={`breakglass-remove-${row.actor_id}`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Rotate-password modal */}
|
||||
{pwdModalActorID && (
|
||||
<RotatePasswordModal
|
||||
actorID={pwdModalActorID}
|
||||
onClose={() => setPwdModalActorID(null)}
|
||||
onSubmit={async pwd => {
|
||||
await setPwd.mutateAsync({ actorID: pwdModalActorID, password: pwd });
|
||||
setPwdModalActorID(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Remove-credential confirmation modal */}
|
||||
{removeModalActorID && (
|
||||
<RemoveCredentialModal
|
||||
actorID={removeModalActorID}
|
||||
onClose={() => setRemoveModalActorID(null)}
|
||||
onConfirm={async () => {
|
||||
await remove.mutateAsync(removeModalActorID);
|
||||
setRemoveModalActorID(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RotatePasswordModal({
|
||||
actorID,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
actorID: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (pwd: string) => Promise<void>;
|
||||
}) {
|
||||
const [pwd, setPwd] = useState('');
|
||||
const [pwdConfirm, setPwdConfirm] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
data-testid="breakglass-rotate-modal"
|
||||
>
|
||||
<div className="bg-surface rounded-lg p-6 max-w-md w-full shadow-xl">
|
||||
<h3 className="text-lg font-semibold mb-2">Rotate password for {actorID}</h3>
|
||||
<p className="text-xs text-ink-muted mb-4">
|
||||
This revokes every active session for the target actor after the password is rotated.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (pwd !== pwdConfirm) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
if (pwd.length < 12) {
|
||||
setError('Password must be at least 12 characters.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(pwd);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Rotation failed.');
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
value={pwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="New password (≥12 chars)"
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm focus:outline-none focus:border-brand-400"
|
||||
data-testid="breakglass-rotate-password"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={pwdConfirm}
|
||||
onChange={e => setPwdConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm focus:outline-none focus:border-brand-400"
|
||||
data-testid="breakglass-rotate-password-confirm"
|
||||
/>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !pwd || !pwdConfirm}
|
||||
className="bg-brand-400 hover:bg-brand-500 text-white px-4 py-2 text-sm font-medium rounded disabled:opacity-50"
|
||||
data-testid="breakglass-rotate-submit"
|
||||
>
|
||||
{submitting ? 'Rotating…' : 'Rotate'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveCredentialModal({
|
||||
actorID,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
actorID: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const matched = confirmText === actorID;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
data-testid="breakglass-remove-modal"
|
||||
>
|
||||
<div className="bg-surface rounded-lg p-6 max-w-md w-full shadow-xl">
|
||||
<h3 className="text-lg font-semibold mb-2 text-red-700">Remove break-glass credential</h3>
|
||||
<p className="text-sm text-ink-muted mb-4">
|
||||
This deletes the break-glass credential for{' '}
|
||||
<code className="bg-page px-1 rounded text-xs">{actorID}</code>. The actor will not be
|
||||
able to use the break-glass login path until a new password is set.
|
||||
</p>
|
||||
<p className="text-xs text-ink-muted mb-2">Type the actor ID to confirm:</p>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={e => setConfirmText(e.target.value)}
|
||||
placeholder={actorID}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm mb-4 focus:outline-none focus:border-red-400"
|
||||
data-testid="breakglass-remove-confirm-input"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!matched || submitting}
|
||||
onClick={async () => {
|
||||
setSubmitting(true);
|
||||
await onConfirm();
|
||||
}}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 text-sm font-medium rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-testid="breakglass-remove-confirm-submit"
|
||||
>
|
||||
{submitting ? 'Removing…' : 'Remove credential'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user