245 lines
11 KiB
JavaScript
245 lines
11 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api';
|
|
import { cn } from '@/lib/utils';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
import {
|
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { LogIn, UserCheck, ShieldCheck } from 'lucide-react';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
|
|
export default function LoginModeCard({ users, onModeChange }) {
|
|
const [modeData, setModeData] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [selected, setSelected] = useState('multi'); // local UI selection
|
|
const [selectedUser, setSelectedUser] = useState('');
|
|
const [confirmSingle, setConfirmSingle] = useState(false);
|
|
|
|
useEffect(() => {
|
|
api.authModeConfig()
|
|
.then(d => {
|
|
setModeData(d);
|
|
const mode = d.auth_mode === 'single' ? 'single' : 'multi';
|
|
setSelected(mode);
|
|
setSelectedUser(d.default_user_id?.toString() || '');
|
|
onModeChange?.(mode);
|
|
})
|
|
.catch(err => setLoadError(err.message || 'Failed to load login mode config'))
|
|
.finally(() => setLoading(false));
|
|
}, []); // eslint-disable-line
|
|
|
|
const doSetMode = async (mode, userId) => {
|
|
setSaving(true);
|
|
try {
|
|
await api.setAuthMode({
|
|
auth_mode: mode,
|
|
default_user_id: mode === 'single' ? parseInt(userId, 10) : null,
|
|
});
|
|
const d = await api.authModeConfig();
|
|
setModeData(d);
|
|
const resolved = d.auth_mode === 'single' ? 'single' : 'multi';
|
|
setSelected(resolved);
|
|
setSelectedUser(d.default_user_id?.toString() || '');
|
|
onModeChange?.(resolved);
|
|
toast.success(mode === 'single' ? 'No Login mode enabled.' : 'Login requirement restored.');
|
|
} catch (err) {
|
|
toast.error(err.message || 'Failed to update login mode.');
|
|
// revert UI selection on error
|
|
setSelected(modeData?.auth_mode === 'single' ? 'single' : 'multi');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (selected === 'single') {
|
|
if (!selectedUser) { toast.error('Select a user account first.'); return; }
|
|
setConfirmSingle(true);
|
|
} else {
|
|
doSetMode('multi', null);
|
|
}
|
|
};
|
|
|
|
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading…</CardContent></Card>;
|
|
if (loadError) return <Card><CardContent className="py-8 text-center text-sm text-destructive">{loadError}</CardContent></Card>;
|
|
|
|
const currentMode = modeData?.auth_mode === 'single' ? 'single' : 'multi';
|
|
const isDirty = selected !== currentMode || (selected === 'single' && selectedUser !== (modeData?.default_user_id?.toString() || ''));
|
|
const activeUser = users?.find(u => u.id === modeData?.default_user_id);
|
|
const pendingUsername = users?.find(u => u.id.toString() === selectedUser)?.username ?? selectedUser;
|
|
const regularUsers = (users || []).filter(u => u.role === 'user');
|
|
|
|
return (
|
|
<>
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Login Mode</CardTitle>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Choose how users access this app.
|
|
</p>
|
|
</div>
|
|
<TooltipProvider delayDuration={300}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge className={cn(
|
|
'cursor-default',
|
|
currentMode === 'single'
|
|
? 'bg-violet-500/15 text-violet-400 border-violet-500/20'
|
|
: 'bg-sky-500/15 text-sky-400 border-sky-500/20',
|
|
)}>
|
|
{currentMode === 'single' ? 'No Login' : 'Require Login'}
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{currentMode === 'single'
|
|
? 'Anyone who opens the app is automatically signed in'
|
|
: 'Users must authenticate to access the app'}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-3">
|
|
{/* Option: Require Login */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelected('multi')}
|
|
className={`w-full text-left rounded-lg border px-4 py-3.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
selected === 'multi'
|
|
? 'border-primary/50 bg-primary/5'
|
|
: 'border-border/60 bg-muted/20 hover:border-border hover:bg-muted/40'
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${
|
|
selected === 'multi' ? 'border-primary' : 'border-muted-foreground/40'
|
|
}`}>
|
|
{selected === 'multi' && <div className="h-2 w-2 rounded-full bg-primary" />}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<LogIn className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">Require Login</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Users must sign in with a username and password, or via OIDC. Authentication methods are configured below.
|
|
</p>
|
|
{currentMode === 'multi' && (
|
|
<p className="text-xs text-emerald-500 mt-1 font-medium">Currently active</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Option: No Login */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelected('single')}
|
|
className={`w-full text-left rounded-lg border px-4 py-3.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
selected === 'single'
|
|
? 'border-violet-500/50 bg-violet-500/5'
|
|
: 'border-border/60 bg-muted/20 hover:border-border hover:bg-muted/40'
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${
|
|
selected === 'single' ? 'border-violet-500' : 'border-muted-foreground/40'
|
|
}`}>
|
|
{selected === 'single' && <div className="h-2 w-2 rounded-full bg-violet-500" />}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<UserCheck className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">No Login — Single User</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Anyone who opens the app is automatically signed in as the selected user. No login screen is shown.
|
|
Admin routes still require a session.
|
|
</p>
|
|
{currentMode === 'single' && (
|
|
<p className="text-xs text-violet-400 mt-1 font-medium">
|
|
Currently active — signed in as <span className="text-foreground">{activeUser?.username ?? '—'}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* User selector — shown only when No Login is selected */}
|
|
{selected === 'single' && (
|
|
<div className="rounded-lg border border-border/60 bg-muted/10 px-4 py-3 space-y-2">
|
|
<Label className="text-xs text-muted-foreground">Auto sign-in as</Label>
|
|
<Select value={selectedUser} onValueChange={setSelectedUser}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a user account…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{regularUsers.map(u => (
|
|
<SelectItem key={u.id} value={u.id.toString()}>{u.username}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{regularUsers.length === 0 && (
|
|
<p className="text-xs text-amber-500">No regular user accounts found. Create a user account first.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Security note for single mode */}
|
|
{selected === 'single' && (
|
|
<div className="flex gap-2 rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2.5">
|
|
<ShieldCheck className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
|
Only use this on trusted private networks. Anyone with access to the URL is signed in automatically.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{isDirty && (
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving || (selected === 'single' && !selectedUser)}
|
|
className="w-full"
|
|
variant={selected === 'single' ? 'default' : 'outline'}
|
|
>
|
|
{saving ? 'Saving…' : selected === 'single' ? 'Enable No Login Mode' : 'Restore Login Requirement'}
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<AlertDialog open={confirmSingle} onOpenChange={setConfirmSingle}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Enable No Login Mode?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Anyone who opens the app will be automatically signed in as{' '}
|
|
<span className="font-medium text-foreground">{pendingUsername}</span>.
|
|
The admin login still requires a password.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => { setConfirmSingle(false); doSetMode('single', selectedUser); }}>
|
|
Enable No Login Mode
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|