feat: Entra ID group restriction, remove redirect URI field, user delete + email edit
This commit is contained in:
@ -96,7 +96,7 @@ const _defaultSettings: [string, string][] = [
|
|||||||
['azure_client_id', ''],
|
['azure_client_id', ''],
|
||||||
['azure_tenant_id', ''],
|
['azure_tenant_id', ''],
|
||||||
['azure_client_secret', ''],
|
['azure_client_secret', ''],
|
||||||
['azure_redirect_uri', ''],
|
['azure_allowed_group', ''],
|
||||||
['checkmk_api_url', ''],
|
['checkmk_api_url', ''],
|
||||||
['checkmk_api_secret', ''],
|
['checkmk_api_secret', ''],
|
||||||
['checkmk_sync_interval_ms', '60000'],
|
['checkmk_sync_interval_ms', '60000'],
|
||||||
|
|||||||
67
server.ts
67
server.ts
@ -164,7 +164,7 @@ async function startServer() {
|
|||||||
return res.redirect('/?auth_error=Azure+login+not+configured');
|
return res.redirect('/?auth_error=Azure+login+not+configured');
|
||||||
}
|
}
|
||||||
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
||||||
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
|
const redirectUri = `${appUrl}/api/auth/azure/callback`;
|
||||||
try {
|
try {
|
||||||
const authCodeUrl = await msalClient.getAuthCodeUrl({
|
const authCodeUrl = await msalClient.getAuthCodeUrl({
|
||||||
scopes: ['openid', 'profile', 'email'],
|
scopes: ['openid', 'profile', 'email'],
|
||||||
@ -192,7 +192,7 @@ async function startServer() {
|
|||||||
return res.redirect('/?auth_error=Azure+login+not+configured');
|
return res.redirect('/?auth_error=Azure+login+not+configured');
|
||||||
}
|
}
|
||||||
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
||||||
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
|
const redirectUri = `${appUrl}/api/auth/azure/callback`;
|
||||||
try {
|
try {
|
||||||
const result = await msalClient.acquireTokenByCode({
|
const result = await msalClient.acquireTokenByCode({
|
||||||
code: String(code),
|
code: String(code),
|
||||||
@ -204,6 +204,13 @@ async function startServer() {
|
|||||||
if (!email) {
|
if (!email) {
|
||||||
return res.redirect('/?auth_error=No+email+returned+by+Microsoft');
|
return res.redirect('/?auth_error=No+email+returned+by+Microsoft');
|
||||||
}
|
}
|
||||||
|
const allowedGroup = getSetting('azure_allowed_group');
|
||||||
|
if (allowedGroup) {
|
||||||
|
const claims = result.idTokenClaims as { groups?: string[] } | undefined;
|
||||||
|
if (!claims?.groups?.includes(allowedGroup)) {
|
||||||
|
return res.redirect('/?auth_error=Not+a+member+of+the+required+group');
|
||||||
|
}
|
||||||
|
}
|
||||||
let user = db.prepare('SELECT id, name, role, email FROM users WHERE email = ?').get(email) as User | undefined;
|
let user = db.prepare('SELECT id, name, role, email FROM users WHERE email = ?').get(email) as User | undefined;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const id = uid("u");
|
const id = uid("u");
|
||||||
@ -258,6 +265,41 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/users/:id', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id;
|
||||||
|
const { name, email } = req.body as { name?: string; email?: string };
|
||||||
|
if (!name && !email) return res.status(400).json({ error: 'Nothing to update.' });
|
||||||
|
const existing = db.prepare('SELECT id, name, email FROM users WHERE id = ?').get(id) as User | undefined;
|
||||||
|
if (!existing) return res.status(404).json({ error: 'User not found.' });
|
||||||
|
if (email && email !== existing.email) {
|
||||||
|
const dupe = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id);
|
||||||
|
if (dupe) return res.status(409).json({ error: 'Email already in use.' });
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE users SET name = COALESCE(?, name), email = COALESCE(?, email) WHERE id = ?')
|
||||||
|
.run(name ?? null, email ?? null, id);
|
||||||
|
const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/users/:id', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id;
|
||||||
|
if (id === req.user!.userId) return res.status(400).json({ error: 'You cannot delete your own account.' });
|
||||||
|
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'User not found.' });
|
||||||
|
const count = (db.prepare('SELECT COUNT(*) as n FROM users').get() as { n: number }).n;
|
||||||
|
if (count <= 1) return res.status(400).json({ error: 'Cannot delete the last user.' });
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// RESTFUL API: Devices / Inventory
|
// RESTFUL API: Devices / Inventory
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@ -656,13 +698,20 @@ async function startServer() {
|
|||||||
.all() as { id: string; hostname: string; checkMkUrl: string }[];
|
.all() as { id: string; hostname: string; checkMkUrl: string }[];
|
||||||
for (const dev of rows) {
|
for (const dev of rows) {
|
||||||
try {
|
try {
|
||||||
// TODO(checkmk): query the host's hard state from the CheckMK API using the
|
const cmkHost = (() => {
|
||||||
// automation secret, map 0 (UP) -> 'online' and anything else -> 'offline':
|
try { return new URL(dev.checkMkUrl).searchParams.get('host') ?? dev.hostname; }
|
||||||
// const res = await fetch(`${CHECKMK_API_URL}/objects/host/${host}/actions/...`,
|
catch { return dev.hostname; }
|
||||||
// { headers: { Authorization: `Bearer automation ${CHECKMK_API_SECRET}` } });
|
})();
|
||||||
// const state = (await res.json()).extensions.state === 0 ? 'online' : 'offline';
|
const res = await fetch(
|
||||||
// db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?')
|
`${CHECKMK_API_URL}/objects/host/${encodeURIComponent(cmkHost)}`,
|
||||||
// .run(state, new Date().toISOString(), dev.id);
|
{ headers: { Authorization: `Bearer automation ${CHECKMK_API_SECRET}`, Accept: 'application/json' } }
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const hardState: number = data?.extensions?.state ?? -1;
|
||||||
|
const newStatus = hardState === 0 ? 'online' : 'offline';
|
||||||
|
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?')
|
||||||
|
.run(newStatus, new Date().toISOString(), dev.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[CheckMK] Status sync failed for ${dev.hostname}:`, err);
|
console.error(`[CheckMK] Status sync failed for ${dev.hostname}:`, err);
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/App.tsx
21
src/App.tsx
@ -338,6 +338,25 @@ export default function App() {
|
|||||||
} catch (err) { console.error('[App] Error adding log:', err); }
|
} catch (err) { console.error('[App] Error adding log:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// User handlers
|
||||||
|
const handleDeleteUser = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) setUsers(prev => prev.filter(u => u.id !== id));
|
||||||
|
else { const d = await res.json(); throw new Error(d.error); }
|
||||||
|
} catch (err: any) { throw err; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateUser = async (id: string, name: string, email: string) => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ name, email }) });
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
setUsers(prev => prev.map(u => u.id === id ? updated : u));
|
||||||
|
} else { const d = await res.json(); throw new Error(d.error); }
|
||||||
|
} catch (err: any) { throw err; }
|
||||||
|
};
|
||||||
|
|
||||||
// Quick-link handlers (shared link dashboard)
|
// Quick-link handlers (shared link dashboard)
|
||||||
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
|
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
|
||||||
try {
|
try {
|
||||||
@ -590,6 +609,8 @@ export default function App() {
|
|||||||
users={users}
|
users={users}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
bookings={bookings}
|
bookings={bookings}
|
||||||
|
onDeleteUser={handleDeleteUser}
|
||||||
|
onUpdateUser={handleUpdateUser}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'logs' && (
|
{activeTab === 'logs' && (
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { authFetch } from '../lib/auth';
|
|||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import {
|
import {
|
||||||
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
|
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
|
||||||
Settings2, ExternalLink, KeyRound, Globe, Clock, ChevronRight,
|
Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const SECRET_SENTINEL = '__SET__';
|
const SECRET_SENTINEL = '__SET__';
|
||||||
@ -13,7 +13,7 @@ interface RawSettings {
|
|||||||
azure_client_id: string;
|
azure_client_id: string;
|
||||||
azure_tenant_id: string;
|
azure_tenant_id: string;
|
||||||
azure_client_secret: string;
|
azure_client_secret: string;
|
||||||
azure_redirect_uri: string;
|
azure_allowed_group: string;
|
||||||
checkmk_api_url: string;
|
checkmk_api_url: string;
|
||||||
checkmk_api_secret: string;
|
checkmk_api_secret: string;
|
||||||
checkmk_sync_interval_ms: string;
|
checkmk_sync_interval_ms: string;
|
||||||
@ -28,7 +28,7 @@ function Label({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Hint({ children }: { children: React.ReactNode }) {
|
function Hint({ children }: { children: React.ReactNode }) {
|
||||||
return <p className="mt-1 text-[10px] text-slate-600 font-mono">{children}</p>;
|
return <p className="mt-1 text-[10px] text-slate-500 font-mono leading-relaxed">{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfiguredBadge() {
|
function ConfiguredBadge() {
|
||||||
@ -68,7 +68,7 @@ function Input({ value, onChange, placeholder, monospace, icon }: {
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{icon && (
|
{icon && (
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-600 pointer-events-none">
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -92,7 +92,7 @@ function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-600 pointer-events-none">
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
|
||||||
<KeyRound className="w-3.5 h-3.5" />
|
<KeyRound className="w-3.5 h-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@ -105,7 +105,7 @@ function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleShow}
|
onClick={onToggleShow}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-300 transition-colors"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{show ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
{show ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||||
@ -119,7 +119,7 @@ function SectionCard({ accentColor, children }: {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={`bg-[#0F172A] border border-[#1E293B] rounded-2xl overflow-hidden`}>
|
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl overflow-hidden">
|
||||||
<div className={`h-0.5 w-full ${accentColor}`} />
|
<div className={`h-0.5 w-full ${accentColor}`} />
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
{children}
|
{children}
|
||||||
@ -133,6 +133,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [successMsg, setSuccessMsg] = useState('');
|
const [successMsg, setSuccessMsg] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const [effectiveRedirectUri, setEffectiveRedirectUri] = useState('');
|
const [effectiveRedirectUri, setEffectiveRedirectUri] = useState('');
|
||||||
|
|
||||||
@ -141,7 +142,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
const [azureTenantId, setAzureTenantId] = useState('');
|
const [azureTenantId, setAzureTenantId] = useState('');
|
||||||
const [azureClientSecret, setAzureClientSecret] = useState('');
|
const [azureClientSecret, setAzureClientSecret] = useState('');
|
||||||
const [azureSecretSet, setAzureSecretSet] = useState(false);
|
const [azureSecretSet, setAzureSecretSet] = useState(false);
|
||||||
const [azureRedirectUri, setAzureRedirectUri] = useState('');
|
const [azureAllowedGroup, setAzureAllowedGroup] = useState('');
|
||||||
const [showAzureSecret, setShowAzureSecret] = useState(false);
|
const [showAzureSecret, setShowAzureSecret] = useState(false);
|
||||||
|
|
||||||
const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
|
const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
|
||||||
@ -175,7 +176,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
setAzureTenantId(data.azure_tenant_id || '');
|
setAzureTenantId(data.azure_tenant_id || '');
|
||||||
setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL);
|
setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL);
|
||||||
setAzureClientSecret('');
|
setAzureClientSecret('');
|
||||||
setAzureRedirectUri(data.azure_redirect_uri || '');
|
setAzureAllowedGroup(data.azure_allowed_group || '');
|
||||||
setCheckmkApiUrl(data.checkmk_api_url || '');
|
setCheckmkApiUrl(data.checkmk_api_url || '');
|
||||||
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
||||||
setCheckmkApiSecret('');
|
setCheckmkApiSecret('');
|
||||||
@ -195,7 +196,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
azure_enabled: azureEnabled ? 'true' : 'false',
|
azure_enabled: azureEnabled ? 'true' : 'false',
|
||||||
azure_client_id: azureClientId,
|
azure_client_id: azureClientId,
|
||||||
azure_tenant_id: azureTenantId,
|
azure_tenant_id: azureTenantId,
|
||||||
azure_redirect_uri: azureRedirectUri,
|
azure_allowed_group: azureAllowedGroup,
|
||||||
checkmk_api_url: checkmkApiUrl,
|
checkmk_api_url: checkmkApiUrl,
|
||||||
checkmk_sync_interval_ms: checkmkSyncInterval,
|
checkmk_sync_interval_ms: checkmkSyncInterval,
|
||||||
};
|
};
|
||||||
@ -222,6 +223,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyRedirectUri() {
|
||||||
|
if (!effectiveRedirectUri) return;
|
||||||
|
navigator.clipboard.writeText(effectiveRedirectUri).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@ -261,7 +270,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
|
|
||||||
{/* ── Microsoft Entra ID ── */}
|
{/* ── Microsoft Entra ID ── */}
|
||||||
<SectionCard accentColor="bg-gradient-to-r from-blue-600 to-indigo-600">
|
<SectionCard accentColor="bg-gradient-to-r from-blue-600 to-indigo-600">
|
||||||
{/* Card header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-blue-950/60 border border-blue-900/40 rounded-xl">
|
<div className="p-2 bg-blue-950/60 border border-blue-900/40 rounded-xl">
|
||||||
@ -280,7 +288,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
<p className="text-[11px] text-slate-500 mt-0.5">OAuth 2.0 SSO for organizational accounts</p>
|
<p className="text-[11px] text-slate-500 mt-0.5">OAuth 2.0 SSO for organizational accounts</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Toggle */}
|
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className={`text-[10px] font-semibold font-mono ${azureEnabled ? 'text-blue-400' : 'text-slate-600'}`}>
|
<span className={`text-[10px] font-semibold font-mono ${azureEnabled ? 'text-blue-400' : 'text-slate-600'}`}>
|
||||||
{azureEnabled ? 'ENABLED' : 'DISABLED'}
|
{azureEnabled ? 'ENABLED' : 'DISABLED'}
|
||||||
@ -297,7 +304,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
|
|
||||||
<div className="h-px bg-slate-800/60" />
|
<div className="h-px bg-slate-800/60" />
|
||||||
|
|
||||||
{/* Fields */}
|
|
||||||
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||||||
<FieldRow label="Tenant ID" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
<FieldRow label="Tenant ID" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
||||||
<Input value={azureTenantId} onChange={setAzureTenantId} placeholder="Paste Tenant ID" monospace />
|
<Input value={azureTenantId} onChange={setAzureTenantId} placeholder="Paste Tenant ID" monospace />
|
||||||
@ -317,26 +323,38 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
onToggleShow={() => setShowAzureSecret(v => !v)}
|
onToggleShow={() => setShowAzureSecret(v => !v)}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow label="Redirect URI" hint="Leave blank to auto-derive from APP_URL">
|
<FieldRow
|
||||||
|
label="Allowed Group"
|
||||||
|
hint="Object ID of an Azure AD group. If set, only members of this group can sign in."
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={azureRedirectUri}
|
value={azureAllowedGroup}
|
||||||
onChange={setAzureRedirectUri}
|
onChange={setAzureAllowedGroup}
|
||||||
placeholder="https://…/api/auth/azure/callback"
|
placeholder="Leave blank to allow all tenant users"
|
||||||
monospace
|
monospace
|
||||||
icon={<ExternalLink className="w-3.5 h-3.5" />}
|
icon={<Users className="w-3.5 h-3.5" />}
|
||||||
/>
|
/>
|
||||||
{effectiveRedirectUri && (
|
|
||||||
<div className="mt-2 flex items-start gap-2 bg-amber-950/30 border border-amber-900/40 rounded-lg px-3 py-2">
|
|
||||||
<span className="text-amber-400 mt-0.5 shrink-0">⚠</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[10px] text-amber-300 font-semibold mb-0.5">Register this URI in Azure Portal</p>
|
|
||||||
<p className="text-[10px] font-mono text-amber-200 break-all">{effectiveRedirectUri}</p>
|
|
||||||
<p className="text-[10px] text-amber-400/70 mt-0.5">App registrations → Authentication → Redirect URIs</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Redirect URI – read-only */}
|
||||||
|
{effectiveRedirectUri && azureEnabled && (
|
||||||
|
<div className="flex items-start gap-3 bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1">Required Redirect URI</p>
|
||||||
|
<p className="text-[11px] font-mono text-slate-200 break-all">{effectiveRedirectUri}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">Register this in Azure Portal → App registrations → Authentication → Redirect URIs</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyRedirectUri}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg text-slate-500 hover:text-slate-200 hover:bg-slate-800 transition-all"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
{copied ? <CheckCircle className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{/* ── CheckMK ── */}
|
{/* ── CheckMK ── */}
|
||||||
@ -403,7 +421,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 disabled:bg-slate-800 disabled:text-slate-600 disabled:border disabled:border-slate-700 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg shadow-cyan-950/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40"
|
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 disabled:bg-slate-800 disabled:text-slate-600 disabled:border disabled:border-slate-700 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg shadow-cyan-950/50"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
|||||||
@ -1,19 +1,15 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { User, Booking } from '../types';
|
import { User, Booking } from '../types';
|
||||||
import { Users, Search, Mail, Calendar, Activity } from 'lucide-react';
|
import { Users, Search, Mail, Calendar, Activity, Trash2, Pencil, X, Save, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface UserDirectoryProps {
|
interface UserDirectoryProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
bookings: Booking[];
|
bookings: Booking[];
|
||||||
|
onDeleteUser: (id: string) => Promise<void>;
|
||||||
|
onUpdateUser: (id: string, name: string, email: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic accent so a given user always renders the same colour.
|
|
||||||
const AVATAR_COLORS = [
|
const AVATAR_COLORS = [
|
||||||
'from-emerald-500 to-teal-600',
|
'from-emerald-500 to-teal-600',
|
||||||
'from-cyan-500 to-blue-600',
|
'from-cyan-500 to-blue-600',
|
||||||
@ -35,8 +31,96 @@ function initials(name: string): string {
|
|||||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserDirectory({ users, currentUser, bookings }: UserDirectoryProps) {
|
interface EditModalProps {
|
||||||
|
user: User;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (name: string, email: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditModal({ user, onClose, onSave }: EditModalProps) {
|
||||||
|
const [name, setName] = useState(user.name);
|
||||||
|
const [email, setEmail] = useState(user.email);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !email.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await onSave(name.trim(), email.trim().toLowerCase());
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to save.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/70 backdrop-blur-sm">
|
||||||
|
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl shadow-2xl w-full max-w-md">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Edit User</h3>
|
||||||
|
<button onClick={onClose} className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-all">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 bg-red-950/50 border border-red-900/50 rounded-lg px-3 py-2 text-xs text-red-300">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wide">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wide">Email address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition-all"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 text-white transition-all"
|
||||||
|
>
|
||||||
|
{saving ? <span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserDirectory({ users, currentUser, bookings, onDeleteUser, onUpdateUser }: UserDirectoryProps) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const bookingCount = useMemo(() => {
|
const bookingCount = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
@ -63,6 +147,16 @@ export default function UserDirectory({ users, currentUser, bookings }: UserDire
|
|||||||
);
|
);
|
||||||
}, [users, search]);
|
}, [users, search]);
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
setDeletingId(id);
|
||||||
|
try { await onDeleteUser(id); } finally { setDeletingId(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(name: string, email: string) {
|
||||||
|
if (!editingUser) return;
|
||||||
|
await onUpdateUser(editingUser.id, name, email);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 font-sans" id="user-directory-root">
|
<div className="space-y-6 font-sans" id="user-directory-root">
|
||||||
|
|
||||||
@ -77,9 +171,8 @@ export default function UserDirectory({ users, currentUser, bookings }: UserDire
|
|||||||
Registered Operators
|
Registered Operators
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
|
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
|
||||||
Everyone with an account on this box. booking counts come straight from the shared reservation pool - no shadow IT here.
|
Everyone with an account on this box. Booking counts come straight from the shared reservation pool.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 pt-3">
|
<div className="flex flex-wrap gap-2 pt-3">
|
||||||
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
|
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
|
||||||
<Users className="w-3.5 h-3.5 text-emerald-400" />
|
<Users className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
@ -99,12 +192,12 @@ Everyone with an account on this box. booking counts come straight from the shar
|
|||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
|
<span className="absolute left-3 top-2.5 text-slate-500"><Search className="w-4 h-4" /></span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search operators by name, email or role…"
|
placeholder="Search operators by name, email or role…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="w-full bg-[#1E293B] text-slate-100 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-emerald-600"
|
className="w-full bg-[#1E293B] text-slate-100 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-emerald-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -118,6 +211,7 @@ Everyone with an account on this box. booking counts come straight from the shar
|
|||||||
const isMe = user.id === currentUser.id;
|
const isMe = user.id === currentUser.id;
|
||||||
const total = bookingCount.get(user.id) ?? 0;
|
const total = bookingCount.get(user.id) ?? 0;
|
||||||
const active = activeCount.get(user.id) ?? 0;
|
const active = activeCount.get(user.id) ?? 0;
|
||||||
|
const isDeleting = deletingId === user.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
@ -145,18 +239,43 @@ Everyone with an account on this box. booking counts come straight from the shar
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-slate-850 flex items-center justify-between">
|
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center justify-between gap-2">
|
||||||
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">Operator</span>
|
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">Operator</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex items-center gap-1" title="Total bookings">
|
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
|
||||||
<Calendar className="w-3 h-3 text-indigo-400" />
|
<span className="flex items-center gap-1" title="Total bookings">
|
||||||
{total}
|
<Calendar className="w-3 h-3 text-indigo-400" />
|
||||||
</span>
|
{total}
|
||||||
<span className="flex items-center gap-1" title="Active / upcoming bookings">
|
</span>
|
||||||
<Activity className="w-3 h-3 text-emerald-400" />
|
<span className="flex items-center gap-1" title="Active / upcoming bookings">
|
||||||
{active}
|
<Activity className="w-3 h-3 text-emerald-400" />
|
||||||
</span>
|
{active}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingUser(user)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-slate-900 transition-all"
|
||||||
|
title="Edit name / email"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
{!isMe && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="p-1.5 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-slate-900 transition-all disabled:opacity-40"
|
||||||
|
title="Delete user"
|
||||||
|
>
|
||||||
|
{isDeleting
|
||||||
|
? <span className="w-3.5 h-3.5 border-2 border-slate-600 border-t-rose-400 rounded-full animate-spin inline-block" />
|
||||||
|
: <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -164,6 +283,14 @@ Everyone with an account on this box. booking counts come straight from the shar
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingUser && (
|
||||||
|
<EditModal
|
||||||
|
user={editingUser}
|
||||||
|
onClose={() => setEditingUser(null)}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user