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>
126 lines
4.9 KiB
JavaScript
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;
|