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>
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { UserPlus, Trash2, Edit2, Shield, User, CheckCircle } from 'lucide-react';
|
||||
import { Trash2, Shield, User, CheckCircle, Info } from 'lucide-react';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
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 [isEditing, setIsEditing] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [formData, setFormData] = useState({ name: '', role: 'user', pin: '' });
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const loadUsers = async () => {
|
||||
@@ -23,29 +24,16 @@ const TeamManager = () => {
|
||||
|
||||
useEffect(() => { loadUsers(); }, []);
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name || !formData.pin) return;
|
||||
|
||||
if (editingId) {
|
||||
await db.updateTeamMember(editingId, { name: formData.name, role: formData.role, pin: formData.pin });
|
||||
setMessage('User updated successfully.');
|
||||
} else {
|
||||
await db.addTeamMember({ name: formData.name, role: formData.role, pin: formData.pin });
|
||||
setMessage('User added successfully.');
|
||||
}
|
||||
|
||||
await loadUsers();
|
||||
setFormData({ name: '', role: 'user', pin: '' });
|
||||
setIsEditing(false);
|
||||
setEditingId(null);
|
||||
const flash = (msg) => {
|
||||
setMessage(msg);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
};
|
||||
|
||||
const handleEdit = (user) => {
|
||||
setFormData({ name: user.name, role: user.role, pin: user.pin });
|
||||
setIsEditing(true);
|
||||
setEditingId(user.id);
|
||||
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) => {
|
||||
@@ -56,24 +44,17 @@ const TeamManager = () => {
|
||||
if (confirm("Are you sure you want to delete this user?")) {
|
||||
await db.deleteTeamMember(id);
|
||||
|
||||
// Also remove from leaderboard
|
||||
// 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();
|
||||
setMessage('User deleted.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
flash('User deleted.');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setEditingId(null);
|
||||
setFormData({ name: '', role: 'user', pin: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{message && (
|
||||
@@ -82,58 +63,22 @@ const TeamManager = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="border border-bg-warm">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2 mb-4">
|
||||
{isEditing ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />}
|
||||
{isEditing ? 'Edit Team Member' : 'Add New Team Member'}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSave} className="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div className="flex-1 w-full">
|
||||
<Input
|
||||
label="Full Name"
|
||||
placeholder="e.g. Jane Doe"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({...formData, name: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-sm font-medium mb-1.5 text-fg">Role</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={e => setFormData({...formData, role: e.target.value})}
|
||||
className="w-full h-11 px-3 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"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<Input
|
||||
label="Login PIN"
|
||||
type="text"
|
||||
placeholder="e.g. 1234"
|
||||
value={formData.pin}
|
||||
onChange={e => setFormData({...formData, pin: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button type="submit" className="w-full sm:w-auto h-11 px-6">
|
||||
{isEditing ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
{isEditing && (
|
||||
<Button type="button" variant="outline" onClick={cancelEdit} className="h-11 px-4">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<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">
|
||||
@@ -145,19 +90,26 @@ const TeamManager = () => {
|
||||
{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">PIN: {user.pin}</p>
|
||||
<p className="text-sm text-fg-muted">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Tag variant={user.role === 'admin' ? 'dark' : 'default'} className="hidden sm:inline-flex">{user.role}</Tag>
|
||||
<button onClick={() => handleEdit(user)} className="p-2 text-fg-muted hover:text-teal transition-colors">
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
57
src/lib/__tests__/azureAuth.test.js
Normal file
57
src/lib/__tests__/azureAuth.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
OIDC_PROVIDER,
|
||||
parseAdminEmails,
|
||||
resolveRole,
|
||||
deriveName,
|
||||
} from '../azureAuth';
|
||||
|
||||
describe('azureAuth', () => {
|
||||
it('exposes the OIDC provider name used by authWithOAuth2', () => {
|
||||
expect(OIDC_PROVIDER).toBe('oidc');
|
||||
});
|
||||
|
||||
describe('parseAdminEmails', () => {
|
||||
it('returns [] for empty/undefined input', () => {
|
||||
expect(parseAdminEmails()).toEqual([]);
|
||||
expect(parseAdminEmails('')).toEqual([]);
|
||||
expect(parseAdminEmails(' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('lower-cases, trims, drops blanks and de-duplicates', () => {
|
||||
expect(parseAdminEmails(' RVE@respellion.nl , admin@respellion.nl ,, rve@respellion.nl'))
|
||||
.toEqual(['rve@respellion.nl', 'admin@respellion.nl']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRole', () => {
|
||||
it('returns admin for an allow-listed e-mail (case-insensitive)', () => {
|
||||
expect(resolveRole('RVE@respellion.nl', 'rve@respellion.nl')).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns user for a non-listed e-mail', () => {
|
||||
expect(resolveRole('jane@respellion.nl', 'rve@respellion.nl')).toBe('user');
|
||||
});
|
||||
|
||||
it('returns user when the allow-list is empty', () => {
|
||||
expect(resolveRole('rve@respellion.nl', '')).toBe('user');
|
||||
expect(resolveRole('rve@respellion.nl')).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveName', () => {
|
||||
it('prefers the OIDC name claim', () => {
|
||||
expect(deriveName({ name: 'Raymond Verhoef' }, 'rve@respellion.nl')).toBe('Raymond Verhoef');
|
||||
});
|
||||
|
||||
it('falls back to the e-mail prefix when no name claim', () => {
|
||||
expect(deriveName({}, 'rve@respellion.nl')).toBe('rve');
|
||||
expect(deriveName(undefined, 'jane.doe@respellion.nl')).toBe('jane.doe');
|
||||
});
|
||||
|
||||
it('falls back to "Onbekend" with no usable input', () => {
|
||||
expect(deriveName({}, '')).toBe('Onbekend');
|
||||
expect(deriveName(null, null)).toBe('Onbekend');
|
||||
});
|
||||
});
|
||||
});
|
||||
56
src/lib/azureAuth.js
Normal file
56
src/lib/azureAuth.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Azure (Entra ID) authentication helpers.
|
||||
*
|
||||
* Canonical, unit-tested home for the small pieces of auth logic shared by the
|
||||
* frontend. The PocketBase hook `pb_hooks/team_members.pb.js` re-implements
|
||||
* `resolveRole` / `parseAdminEmails` in the JSVM runtime (it cannot import this
|
||||
* module) — keep the two in sync.
|
||||
*
|
||||
* Login itself goes through PocketBase's built-in OAuth2 flow:
|
||||
* pb.collection('team_members').authWithOAuth2({ provider: OIDC_PROVIDER })
|
||||
* The OIDC provider ("oidc") is configured against Entra in migration
|
||||
* 1781000000_team_members_to_auth.js.
|
||||
*/
|
||||
|
||||
/** Provider name registered on the `team_members` auth collection. */
|
||||
export const OIDC_PROVIDER = 'oidc';
|
||||
|
||||
/**
|
||||
* Parse the ENTRA_ADMIN_EMAILS-style comma-separated allow-list into a
|
||||
* normalised (lower-cased, trimmed, de-duplicated, empty-free) array.
|
||||
* @param {string} [csv]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function parseAdminEmails(csv) {
|
||||
if (!csv) return [];
|
||||
const seen = new Set();
|
||||
for (const part of String(csv).toLowerCase().split(',')) {
|
||||
const email = part.trim();
|
||||
if (email) seen.add(email);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user's role from their e-mail and the admin allow-list.
|
||||
* @param {string} email
|
||||
* @param {string} [adminEmailsCsv]
|
||||
* @returns {'admin'|'user'}
|
||||
*/
|
||||
export function resolveRole(email, adminEmailsCsv) {
|
||||
const allow = parseAdminEmails(adminEmailsCsv);
|
||||
return allow.includes(String(email || '').trim().toLowerCase()) ? 'admin' : 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a display name from OIDC claims, falling back to the e-mail prefix.
|
||||
* @param {{ name?: string }} [claims]
|
||||
* @param {string} [email]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function deriveName(claims, email) {
|
||||
const fromClaim = claims && typeof claims.name === 'string' ? claims.name.trim() : '';
|
||||
if (fromClaim) return fromClaim;
|
||||
if (email && email.includes('@')) return email.split('@')[0];
|
||||
return 'Onbekend';
|
||||
}
|
||||
@@ -197,7 +197,7 @@ const Admin = () => {
|
||||
{activeTab === 'team' && (
|
||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
|
||||
<p className="text-fg-muted mb-8">Manage team members, roles, and login PINs.</p>
|
||||
<p className="text-fg-muted mb-8">Review team members and manage their roles. Accounts are created automatically via Microsoft (Azure) sign-in.</p>
|
||||
<TeamManager />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,37 +2,25 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import Card from '../components/ui/Card';
|
||||
import Input from '../components/ui/Input';
|
||||
import Select from '../components/ui/Select';
|
||||
import Button from '../components/ui/Button';
|
||||
|
||||
const Login = () => {
|
||||
const { state, login } = useApp();
|
||||
const { loginWithAzure } = useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const userOptions = [
|
||||
{ value: '', label: 'Select a user...' },
|
||||
...state.users.map(u => ({ value: u.id, label: u.name }))
|
||||
];
|
||||
|
||||
const handleLogin = (e) => {
|
||||
e.preventDefault();
|
||||
const handleAzure = async () => {
|
||||
setError('');
|
||||
|
||||
if (!selectedUser || !pin) {
|
||||
setError('Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = login(selectedUser, pin);
|
||||
if (success) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await loginWithAzure();
|
||||
navigate('/');
|
||||
} else {
|
||||
setError('Incorrect PIN.');
|
||||
} catch (e) {
|
||||
// PocketBase throws when the popup is closed or the token exchange fails.
|
||||
setError(e?.message || 'Signing in with Microsoft failed. Please try again.');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,31 +34,17 @@ const Login = () => {
|
||||
</div>
|
||||
|
||||
<Card className="border border-bg-warm">
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-5">
|
||||
<Select
|
||||
label="User"
|
||||
options={userOptions}
|
||||
value={selectedUser}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="PIN Code"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={4}
|
||||
placeholder="••••"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-fg-muted text-sm text-center">
|
||||
Sign in with your Respellion Microsoft account.
|
||||
</p>
|
||||
|
||||
{error && <div className="text-red-500 text-sm font-medium">{error}</div>}
|
||||
{error && <div className="text-red-500 text-sm font-medium text-center">{error}</div>}
|
||||
|
||||
<Button type="submit" className="mt-2 w-full">
|
||||
Sign In
|
||||
<Button onClick={handleAzure} disabled={busy} className="w-full">
|
||||
{busy ? 'Signing in…' : 'Sign in with Microsoft'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import * as db from '../lib/db';
|
||||
import { pb } from '../lib/pb';
|
||||
import { OIDC_PROVIDER } from '../lib/azureAuth';
|
||||
import { getPersonalWeekNumber } from '../lib/curriculumService';
|
||||
|
||||
const AppContext = createContext();
|
||||
@@ -50,44 +51,40 @@ export function AppProvider({ children }) {
|
||||
|
||||
useEffect(() => {
|
||||
const loadState = async () => {
|
||||
let members = await db.getTeamMembers();
|
||||
|
||||
if (!members || members.length === 0) {
|
||||
// Restore the session from PocketBase's persistent auth store. The token
|
||||
// is kept in localStorage by the SDK; authRefresh verifies it is still
|
||||
// valid (and that the Entra account hasn't been revoked) and returns the
|
||||
// up-to-date record.
|
||||
if (pb.authStore.isValid && pb.authStore.record?.collectionName === 'team_members') {
|
||||
try {
|
||||
const created = await db.addTeamMember({ name: 'Admin', role: 'admin', pin: '0000' });
|
||||
members = [created];
|
||||
} catch (e) {
|
||||
console.warn('[AppContext] Could not auto-create admin user:', e.message,
|
||||
'— Run scripts/setup-pb-collections.mjs to configure the database.');
|
||||
}
|
||||
}
|
||||
|
||||
const sessionUserId = sessionStorage.getItem('respellion_session');
|
||||
if (sessionUserId) {
|
||||
const user = members.find(u => u.id === sessionUserId);
|
||||
if (user) {
|
||||
dispatch({ type: 'LOGIN', payload: user });
|
||||
const { record } = await pb.collection('team_members').authRefresh();
|
||||
dispatch({ type: 'LOGIN', payload: record });
|
||||
} catch {
|
||||
// Token rejected (expired / account revoked) — drop the session.
|
||||
pb.authStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// The team list (leaderboard, dashboard) requires authentication now, so
|
||||
// only load it once we have a valid session.
|
||||
const members = pb.authStore.isValid ? await db.getTeamMembers() : [];
|
||||
dispatch({ type: 'INIT_APP', payload: { users: members } });
|
||||
};
|
||||
|
||||
loadState().catch(console.error);
|
||||
}, []);
|
||||
|
||||
const login = (userId, pin) => {
|
||||
const user = state.users.find(u => u.id === userId && u.pin === pin);
|
||||
if (user) {
|
||||
sessionStorage.setItem('respellion_session', user.id);
|
||||
dispatch({ type: 'LOGIN', payload: user });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// Start the Entra (OIDC) login flow. PocketBase opens the provider popup,
|
||||
// handles the token exchange server-side, and auto-provisions the record on
|
||||
// first login (see pb_hooks/team_members.pb.js).
|
||||
const loginWithAzure = async () => {
|
||||
const authData = await pb.collection('team_members').authWithOAuth2({ provider: OIDC_PROVIDER });
|
||||
dispatch({ type: 'LOGIN', payload: authData.record });
|
||||
return authData.record;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
sessionStorage.removeItem('respellion_session');
|
||||
pb.authStore.clear();
|
||||
dispatch({ type: 'LOGOUT' });
|
||||
};
|
||||
|
||||
@@ -107,7 +104,7 @@ export function AppProvider({ children }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ state, dispatch, login, logout, enrollCurrentUser }}>
|
||||
<AppContext.Provider value={{ state, dispatch, loginWithAzure, logout, enrollCurrentUser }}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user