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:
RaymondVerhoef
2026-06-23 11:41:08 +02:00
parent 5f34a6f825
commit 3af105bccd
17 changed files with 734 additions and 170 deletions

View File

@@ -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>
)}

View File

@@ -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>