Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions app/admin/sessions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Clock, MapPin, Monitor, ShieldCheck, XCircle } from 'lucide-react';
import { useRealtimeSessions } from '@/lib/hooks/use-realtime-security';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';

type SessionRecord = {
id: string;
Expand Down Expand Up @@ -35,6 +45,13 @@ export default function ActiveSessionsPage() {
const [sessions, setSessions] = useState<SessionRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Audit 2026-05-23 (Sprint 5a): hold the pending session in state so
// AlertDialog can confirm before revoke. Replaces the browser confirm()
// that lacked focus trap / aria semantics.
const [pendingRevoke, setPendingRevoke] = useState<{
sessionId: string;
user: string;
} | null>(null);

const visibleSessions = useMemo(() => sessions.slice(0, 100), [sessions]);

Expand Down Expand Up @@ -77,8 +94,6 @@ export default function ActiveSessionsPage() {

const revokeSession = useCallback(
async (sessionId: string) => {
if (!confirm('Revoke this session? The user will be logged out.')) return;

try {
const res = await fetch('/api/session/revoke', {
method: 'POST',
Expand Down Expand Up @@ -215,7 +230,13 @@ export default function ActiveSessionsPage() {
</div>

<button
onClick={() => void revokeSession(session.session_id)}
onClick={() =>
setPendingRevoke({
sessionId: session.session_id,
user:
session.user.full_name ?? session.user.email ?? 'Unknown user',
})
}
className="inline-flex items-center gap-2 rounded-lg border border-red-700/50 bg-red-900/20 px-3 py-2 text-sm font-medium text-red-300 hover:bg-red-900/30"
title="Revoke Session"
>
Expand All @@ -237,6 +258,36 @@ export default function ActiveSessionsPage() {
</p>
)}
</div>

<AlertDialog
open={pendingRevoke !== null}
onOpenChange={(open) => {
if (!open) setPendingRevoke(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke this session?</AlertDialogTitle>
<AlertDialogDescription>
{pendingRevoke?.user ?? 'The user'} will be logged out
immediately. This is recorded in the security audit log.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pendingRevoke) {
void revokeSession(pendingRevoke.sessionId);
setPendingRevoke(null);
}
}}
>
Revoke session
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
71 changes: 53 additions & 18 deletions components/policies/policy-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import { useState, useEffect } from 'react';
import { updatePolicyContent, publishPolicy } from '@/app/app/policies/actions';
import { Loader2, ArrowLeft, Globe } from 'lucide-react';
import { useRouter } from 'next/navigation';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';

export function PolicyEditor({
policy,
Expand Down Expand Up @@ -34,13 +45,10 @@ export function PolicyEditor({
return () => clearTimeout(timer);
}, [content, policy.id, policy.content]);

// Audit 2026-05-23 (Sprint 5a): publish gate is now AlertDialog. Browser
// confirm() had no focus trap, no audit-log-recorded context, and
// looked broken on mobile.
const handlePublish = async () => {
if (
!confirm(
'Are you sure? This will make the policy live for all employees.',
)
)
return;
setPublishing(true);
await publishPolicy(policy.id);
setPublishing(false);
Expand Down Expand Up @@ -77,18 +85,45 @@ export function PolicyEditor({
Markdown Supported
</span>
{policy.status !== 'published' && (
<button
onClick={handlePublish}
disabled={publishing}
className="flex items-center gap-2 bg-gradient-to-r from-blue-600 via-indigo-600 to-cyan-500 text-white px-4 py-2 rounded-xl text-sm font-bold hover:brightness-110 transition-colors"
>
{publishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Globe className="h-4 w-4" />
)}
Publish
</button>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
disabled={publishing}
className="flex items-center gap-2 bg-gradient-to-r from-blue-600 via-indigo-600 to-cyan-500 text-white px-4 py-2 rounded-xl text-sm font-bold hover:brightness-110 transition-colors"
>
{publishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Globe className="h-4 w-4" />
)}
Publish
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Publish policy?</AlertDialogTitle>
<AlertDialogDescription>
<span className="block text-slate-300">{policy.title}</span>
<span className="mt-2 block">
Publishing makes this policy live for every employee
in the organisation. The current version
({policy.version}) becomes the active one.
Comment on lines +109 to +110
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handlePublish}
disabled={publishing}
>
Publish
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
Expand Down
82 changes: 67 additions & 15 deletions components/team/role-cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
import { useEffect, useRef, useState, useTransition } from 'react';
import { ChevronDown } from 'lucide-react';
import { updateMemberRole } from '@/app/app/actions/team';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';

type RoleOption = 'owner' | 'admin' | 'member' | 'viewer';

Expand Down Expand Up @@ -53,6 +63,12 @@ export function RoleCell({
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const menuRef = useRef<HTMLDivElement>(null);
// Audit 2026-05-23 (Sprint 5a): owner-involved role change now uses
// AlertDialog instead of window.confirm — focus trap + aria semantics
// + matches the rest of the app's destructive-action UX.
const [pendingOwnerChange, setPendingOwnerChange] = useState<RoleOption | null>(
null,
);

// Reconcile local state if a server revalidation changes the prop.
useEffect(() => {
Expand All @@ -79,21 +95,7 @@ export function RoleCell({
);
}

function handleChoose(next: RoleOption) {
setOpen(false);
setError(null);

if (next === role) return;

const ownerInvolved = role === 'owner' || next === 'owner';
if (ownerInvolved) {
const verb = next === 'owner' ? 'promote to owner' : 'demote from owner';
const ok = window.confirm(
`Are you sure you want to ${verb}? Owner is the highest role and these changes are not casual. The org's audit log will record the change.`,
);
if (!ok) return;
}

function applyRoleChange(next: RoleOption) {
// Optimistic update, rolled back if the server action returns an error.
const previous = role;
setRole(next);
Expand All @@ -114,6 +116,21 @@ export function RoleCell({
});
}

function handleChoose(next: RoleOption) {
setOpen(false);
setError(null);

if (next === role) return;

const ownerInvolved = role === 'owner' || next === 'owner';
if (ownerInvolved) {
setPendingOwnerChange(next);
return;
}

applyRoleChange(next);
}

// Owner-involving options are hidden from non-owner actors so a misclick
// doesn't trigger an error message — defense in depth alongside the
// server-side check.
Expand Down Expand Up @@ -181,6 +198,41 @@ export function RoleCell({
{error}
</p>
)}

<AlertDialog
open={pendingOwnerChange !== null}
onOpenChange={(o) => {
if (!o) setPendingOwnerChange(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingOwnerChange === 'owner'
? 'Promote to owner?'
: 'Demote from owner?'}
</AlertDialogTitle>
<AlertDialogDescription>
Owner is the highest role. {pendingOwnerChange === 'owner'
? 'This member will gain full control of the organisation, including billing and member-removal.'
: 'This member will lose owner-level controls.'} The change is recorded in the organisation audit log.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pendingOwnerChange) {
applyRoleChange(pendingOwnerChange);
setPendingOwnerChange(null);
}
}}
>
{pendingOwnerChange === 'owner' ? 'Promote' : 'Demote'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
Loading