Files
learning-platform/src/components/admin/TeamManager.jsx
RaymondVerhoef 3af105bccd feat: Azure (Entra ID) login as user login (#16)
Replaces the client-side PIN login with real authentication against Azure
Entra ID via PocketBase's built-in OAuth2 (OIDC) flow. Every Azure user is
auto-provisioned as a team_member on first login.

- pb_migrations: team_members becomes a PocketBase auth collection (OIDC
  provider configured from env); all collections require @request.auth.id
- pb_hooks: provisioning of role (ENTRA_ADMIN_EMAILS allow-list) and
  enrollment_status, with admin-role re-sync on every login
- frontend: "Sign in with Microsoft" login, pb.authStore-based session,
  TeamManager manages roster/roles (no PIN/manual create)
- infra: Entra env wiring + pb_hooks mount for dev (Labs) and prod
- src/lib/azureAuth.js + tests for the canonical role/allow-list logic
- docs/auth-spec.md

Knowledge base, generated tests and micro-learnings are left untouched.
Existing PIN users are dropped (no migration required, per sign-off).

Closes #16

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:41:08 +02:00

126 lines
4.9 KiB
JavaScript

import { useState, useEffect } from 'react';
import { Trash2, Shield, User, CheckCircle, Info } from 'lucide-react';
import Card from '../ui/Card';
import Tag from '../ui/Tag';
import * as db from '../../lib/db';
import { pb } from '../../lib/pb';
import { useApp } from '../../store/AppContext';
// Users are provisioned automatically on first Azure (Entra ID) login — admins
// no longer create them by hand. This panel lets an admin review the roster,
// switch a member between user/admin, and remove members. The admin role is
// also re-synced from the ENTRA_ADMIN_EMAILS allow-list on every login (see
// pb_hooks/team_members.pb.js), so a manual change here can be overridden on
// the member's next sign-in if their e-mail is on/off that list.
const TeamManager = () => {
const { state } = useApp();
const [users, setUsers] = useState([]);
const [message, setMessage] = useState('');
const loadUsers = async () => {
const members = await db.getTeamMembers();
setUsers(members);
};
useEffect(() => { loadUsers(); }, []);
const flash = (msg) => {
setMessage(msg);
setTimeout(() => setMessage(''), 3000);
};
const handleRoleChange = async (user, role) => {
if (role === user.role) return;
await db.updateTeamMember(user.id, { role });
await loadUsers();
flash(`${user.name} is now ${role}.`);
};
const handleDelete = async (id) => {
if (id === state.currentUser?.id) {
alert("You cannot delete yourself.");
return;
}
if (confirm("Are you sure you want to delete this user?")) {
await db.deleteTeamMember(id);
// Also remove from leaderboard.
try {
const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
await pb.collection('leaderboard').delete(entry.id);
} catch { /* no leaderboard entry, nothing to do */ }
await loadUsers();
flash('User deleted.');
}
};
return (
<div className="space-y-8">
{message && (
<div className="bg-teal-50 text-teal-800 p-3 rounded-[var(--r-sm)] flex items-center gap-2 text-sm">
<CheckCircle size={16} /> {message}
</div>
)}
<Card className="border border-bg-warm bg-bg-warm/30">
<p className="text-sm text-fg-muted flex items-start gap-2">
<Info size={16} className="mt-0.5 shrink-0 text-teal" />
Team members are created automatically when they first sign in with their
Microsoft account. Admins are granted via the <code>ENTRA_ADMIN_EMAILS</code>{' '}
allow-list and re-synced on each login.
</p>
</Card>
<Card className="p-0 border border-bg-warm overflow-hidden">
<div className="divide-y divide-bg-warm">
{users.length === 0 && (
<div className="p-6 text-sm text-fg-muted text-center">
No team members yet they appear here after their first sign-in.
</div>
)}
{users.map(user => (
<div key={user.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-teal/10 text-teal'}`}>
{user.role === 'admin' ? <Shield size={18} /> : <User size={18} />}
</div>
<div>
<p className="font-medium flex items-center gap-2">
{user.name}
{user.id === state.currentUser?.id && <Tag variant="accent" className="text-[10px] py-0">You</Tag>}
</p>
<p className="text-sm text-fg-muted">{user.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={user.role || 'user'}
onChange={e => handleRoleChange(user, e.target.value)}
disabled={user.id === state.currentUser?.id}
className="h-9 px-2 rounded-[var(--r-sm)] border border-bg-warm bg-paper text-sm focus:outline-none focus:ring-2 focus:ring-teal/30 focus:border-teal disabled:opacity-40"
aria-label={`Role for ${user.name}`}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button
onClick={() => handleDelete(user.id)}
disabled={user.id === state.currentUser?.id}
className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
aria-label={`Delete ${user.name}`}
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
</Card>
</div>
);
};
export default TeamManager;