Compare commits

..

4 Commits

Author SHA1 Message Date
e8502577b8 chore: update TASKS.md — phase 5 complete, VER-001 in-progress (#583)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 23:45:35 +00:00
af68f84dcd feat(api): invalidate sessions on user deactivation (MS21-AUTH-004) (#582)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 23:41:11 +00:00
b57f549d39 test(web): add API client tests for admin, workspaces, teams (MS21-TEST-004) (#581)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 23:26:36 +00:00
2c8d0a8daf feat(web): RBAC access guard on users settings page (MS21-RBAC-002/003/004) (#580)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 23:24:20 +00:00
4 changed files with 56 additions and 19 deletions

View File

@@ -24,7 +24,15 @@ describe("AdminService", () => {
workspaceMember: { workspaceMember: {
create: vi.fn(), create: vi.fn(),
}, },
$transaction: vi.fn(), session: {
deleteMany: vi.fn(),
},
$transaction: vi.fn(async (ops) => {
if (typeof ops === "function") {
return ops(mockPrismaService);
}
return Promise.all(ops);
}),
}; };
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001"; const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
@@ -82,10 +90,6 @@ describe("AdminService", () => {
service = module.get<AdminService>(AdminService); service = module.get<AdminService>(AdminService);
vi.clearAllMocks(); vi.clearAllMocks();
mockPrismaService.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => {
return fn(mockPrismaService);
});
}); });
it("should be defined", () => { it("should be defined", () => {
@@ -325,12 +329,13 @@ describe("AdminService", () => {
}); });
describe("deactivateUser", () => { describe("deactivateUser", () => {
it("should set deactivatedAt on the user", async () => { it("should set deactivatedAt and invalidate sessions", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({ mockPrismaService.user.update.mockResolvedValue({
...mockUser, ...mockUser,
deactivatedAt: new Date(), deactivatedAt: new Date(),
}); });
mockPrismaService.session.deleteMany.mockResolvedValue({ count: 3 });
const result = await service.deactivateUser(mockUserId); const result = await service.deactivateUser(mockUserId);
@@ -341,6 +346,7 @@ describe("AdminService", () => {
data: { deactivatedAt: expect.any(Date) }, data: { deactivatedAt: expect.any(Date) },
}) })
); );
expect(mockPrismaService.session.deleteMany).toHaveBeenCalledWith({ where: { userId: mockUserId } });
}); });
it("should throw NotFoundException if user does not exist", async () => { it("should throw NotFoundException if user does not exist", async () => {

View File

@@ -192,7 +192,8 @@ export class AdminService {
throw new BadRequestException(`User ${id} is already deactivated`); throw new BadRequestException(`User ${id} is already deactivated`);
} }
const user = await this.prisma.user.update({ const [user] = await this.prisma.$transaction([
this.prisma.user.update({
where: { id }, where: { id },
data: { deactivatedAt: new Date() }, data: { deactivatedAt: new Date() },
include: { include: {
@@ -202,9 +203,11 @@ export class AdminService {
}, },
}, },
}, },
}); }),
this.prisma.session.deleteMany({ where: { userId: id } }),
]);
this.logger.log(`User deactivated: ${id}`); this.logger.log(`User deactivated and sessions invalidated: ${id}`);
return { return {
id: user.id, id: user.id,

View File

@@ -42,6 +42,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
import { import {
deactivateUser, deactivateUser,
fetchAdminUsers, fetchAdminUsers,
@@ -105,6 +106,8 @@ export default function UsersSettingsPage(): ReactElement {
const [editError, setEditError] = useState<string | null>(null); const [editError, setEditError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => { const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
try { try {
if (showLoadingState) { if (showLoadingState) {
@@ -129,6 +132,20 @@ export default function UsersSettingsPage(): ReactElement {
void loadUsers(true); void loadUsers(true);
}, [loadUsers]); }, [loadUsers]);
useEffect(() => {
fetchUserWorkspaces()
.then((workspaces) => {
const adminRoles: WorkspaceMemberRole[] = [
WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN,
];
setIsAdmin(workspaces.some((ws) => adminRoles.includes(ws.role)));
})
.catch(() => {
setIsAdmin(true); // fail open
});
}, []);
function resetInviteForm(): void { function resetInviteForm(): void {
setInviteForm(INITIAL_INVITE_FORM); setInviteForm(INITIAL_INVITE_FORM);
setInviteError(null); setInviteError(null);
@@ -212,6 +229,17 @@ export default function UsersSettingsPage(): ReactElement {
} }
} }
if (isAdmin === false) {
return (
<div className="p-8 max-w-2xl">
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<p className="text-lg font-semibold text-red-700">Access Denied</p>
<p className="mt-2 text-sm text-red-600">You need Admin or Owner role to manage users.</p>
</div>
</div>
);
}
return ( return (
<div className="max-w-6xl mx-auto p-6 space-y-6"> <div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">

View File

@@ -33,9 +33,9 @@
| MS21-TEST-004 | not-started | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | | | MS21-TEST-004 | not-started | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | |
| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | | | MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | |
| MS21-RBAC-002 | not-started | phase-5 | Settings page access restriction | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | | | MS21-RBAC-002 | not-started | phase-5 | Settings page access restriction | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
| MS21-RBAC-003 | not-started | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | | | MS21-RBAC-003 | done | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
| MS21-RBAC-004 | not-started | phase-5 | User profile role display | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 5K | — | | | MS21-RBAC-004 | done | phase-5 | User profile role display | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 5K | — | |
| MS21-VER-001 | not-started | phase-6 | Full quality gate pass | #571 | stack | — | MS21-TEST-004,MS21-RBAC-004,MS21-MIG-003 | MS21-VER-002 | — | — | — | 5K | — | | | MS21-VER-001 | in-progress | phase-6 | Full quality gate pass | #571 | stack | — | MS21-TEST-004,MS21-RBAC-004,MS21-MIG-003 | MS21-VER-002 | — | — | — | 5K | — | |
| MS21-VER-002 | not-started | phase-6 | Deploy and smoke test | #571 | stack | — | MS21-VER-001 | MS21-VER-003 | — | — | — | 5K | — | | | MS21-VER-002 | not-started | phase-6 | Deploy and smoke test | #571 | stack | — | MS21-VER-001 | MS21-VER-003 | — | — | — | 5K | — | |
| MS21-VER-003 | not-started | phase-6 | Tag v0.0.21 | #571 | stack | — | MS21-VER-002 | — | — | — | — | 2K | — | | | MS21-VER-003 | not-started | phase-6 | Tag v0.0.21 | #571 | stack | — | MS21-VER-002 | — | — | — | — | 2K | — | |