Dispatcharr/frontend/src/components/forms/User.jsx

482 lines
15 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import API from '../../api';
import {
TextInput,
Button,
Modal,
Select,
PasswordInput,
Group,
Stack,
MultiSelect,
ActionIcon,
Switch,
NumberInput,
Tabs,
TagsInput,
Text,
useMantineTheme,
} from '@mantine/core';
import { RotateCcwKey, X } from 'lucide-react';
import { Copy, Key } from 'lucide-react';
import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
import useAuthStore from '../../store/auth';
import { copyToClipboard } from '../../utils';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils';
const User = ({ user = null, isOpen, onClose }) => {
const profiles = useChannelsStore((s) => s.profiles);
const authUser = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const [, setEnableXC] = useState(false);
const [selectedProfiles, setSelectedProfiles] = useState(new Set());
const [generating, setGenerating] = useState(false);
const [generatedKey, setGeneratedKey] = useState(null);
const [userAPIKey, setUserAPIKey] = useState(user?.api_key || null);
const theme = useMantineTheme();
const form = useForm({
mode: 'uncontrolled',
initialValues: {
username: '',
first_name: '',
last_name: '',
email: '',
user_level: '0',
stream_limit: 0,
password: '',
xc_password: '',
xc_allowed_ips: [],
channel_profiles: [],
hide_adult_content: false,
epg_days: 0,
epg_prev_days: 0,
},
validate: (values) => ({
username: !values.username
? 'Username is required'
: values.user_level == USER_LEVELS.STREAMER &&
!values.username.match(/^[a-z0-9]+$/i)
? 'Streamer username must be alphanumeric'
: null,
password:
!user && !values.password && values.user_level != USER_LEVELS.STREAMER
? 'Password is requried'
: null,
xc_password:
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
? 'XC password must be alphanumeric'
: null,
xc_allowed_ips: values.xc_allowed_ips.some(
(cidr) => !cidr.match(IPV4_CIDR_REGEX) && !cidr.match(IPV6_CIDR_REGEX)
)
? 'Each entry must be a valid CIDR range (e.g. 192.168.1.0/24)'
: null,
}),
});
const onChannelProfilesChange = (values) => {
let newValues = new Set(values);
if (selectedProfiles.has('0')) {
newValues.delete('0');
} else if (newValues.has('0')) {
newValues = new Set(['0']);
}
setSelectedProfiles(newValues);
form.setFieldValue('channel_profiles', [...newValues]);
};
const onSubmit = async () => {
const values = form.getValues();
const customProps = user?.custom_properties || {};
// Always save xc_password, even if it's empty (to allow clearing)
customProps.xc_password = values.xc_password || '';
delete values.xc_password;
// Serialize xc_allowed_ips array to comma-separated string for the backend
values.xc_allowed_ips = (values.xc_allowed_ips || []).join(',');
// Save hide_adult_content in custom_properties
customProps.hide_adult_content = values.hide_adult_content || false;
delete values.hide_adult_content;
// Save EPG defaults in custom_properties
customProps.epg_days = values.epg_days || 0;
delete values.epg_days;
customProps.epg_prev_days = values.epg_prev_days || 0;
delete values.epg_prev_days;
values.custom_properties = customProps;
// If 'All' is included, clear this and we assume access to all channels
if (values.channel_profiles.includes('0')) {
values.channel_profiles = [];
}
if (!user && values.user_level == USER_LEVELS.STREAMER) {
// Generate random password - they can't log in, but user can't be created without a password
values.password = Math.random().toString(36).slice(2);
}
if (!user) {
await API.createUser(values);
} else {
if (!values.password) {
delete values.password;
}
const response = await API.updateUser(
user.id,
values,
isAdmin ? false : authUser.id === user.id
);
if (user.id == authUser.id) {
setUser(response);
}
}
form.reset();
setUserAPIKey(null);
onClose();
};
useEffect(() => {
if (user?.id) {
const customProps = user.custom_properties || {};
form.setValues({
username: user.username,
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email,
user_level: `${user.user_level}`,
stream_limit: user.stream_limit || 0,
channel_profiles:
user.channel_profiles.length > 0
? user.channel_profiles.map((id) => `${id}`)
: ['0'],
xc_password: customProps.xc_password || '',
xc_allowed_ips: user.xc_allowed_ips
? user.xc_allowed_ips.split(',').filter(Boolean)
: [],
hide_adult_content: customProps.hide_adult_content || false,
epg_days: customProps.epg_days || 0,
epg_prev_days: customProps.epg_prev_days || 0,
});
if (customProps.xc_password) {
setEnableXC(true);
}
setUserAPIKey(user.api_key || null);
} else {
form.reset();
}
}, [user]);
const generateXCPassword = () => {
form.setValues({
xc_password: Math.random().toString(36).slice(2),
});
};
if (!isOpen) {
return <></>;
}
const isAdmin = authUser.user_level == USER_LEVELS.ADMIN;
const isEditingSelf = authUser.id === user?.id;
const showPermissions = isAdmin && !isEditingSelf;
const canGenerateKey =
authUser.user_level == USER_LEVELS.ADMIN || authUser.id === user?.id;
const onGenerateKey = async () => {
if (!canGenerateKey) {
return;
}
setGenerating(true);
try {
const payload = {};
if (authUser.user_level == USER_LEVELS.ADMIN && user?.id) {
payload.user_id = user.id;
}
const resp = await API.generateApiKey(payload);
const newKey = resp && (resp.key || resp.raw_key);
if (newKey) {
setGeneratedKey(newKey);
setUserAPIKey(newKey);
}
} catch (e) {
// API shows notifications
} finally {
setGenerating(false);
}
};
const onRevokeKey = async () => {
if (!canGenerateKey) return;
setGenerating(true);
try {
const payload = {};
if (authUser.user_level == USER_LEVELS.ADMIN && user?.id) {
payload.user_id = user.id;
}
const resp = await API.revokeApiKey(payload);
// backend returns { success: true } - clear local state
if (resp && resp.success) {
setGeneratedKey(null);
setUserAPIKey(null);
// If we're revoking the current authenticated user's key, update auth store
if (user?.id && authUser?.id === user.id) {
setUser({ ...authUser, api_key: null });
}
}
} catch (e) {
// API shows notifications
} finally {
setGenerating(false);
}
};
return (
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<Tabs defaultValue="account">
<Tabs.List mb="md">
<Tabs.Tab value="account">Account</Tabs.Tab>
{showPermissions && (
<Tabs.Tab value="permissions">Permissions</Tabs.Tab>
)}
<Tabs.Tab value="epg">EPG Defaults</Tabs.Tab>
<Tabs.Tab value="api">API &amp; XC</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="account">
<Stack gap="sm">
<Group grow align="flex-start">
<TextInput
label="Username"
disabled={!isAdmin}
{...form.getInputProps('username')}
key={form.key('username')}
/>
<TextInput
label="E-Mail"
{...form.getInputProps('email')}
key={form.key('email')}
/>
</Group>
<Group grow align="flex-start">
<TextInput
label="First Name"
{...form.getInputProps('first_name')}
key={form.key('first_name')}
/>
<TextInput
label="Last Name"
{...form.getInputProps('last_name')}
key={form.key('last_name')}
/>
</Group>
<PasswordInput
label="Password"
description="Used for UI authentication"
{...form.getInputProps('password')}
key={form.key('password')}
disabled={form.getValues().user_level == USER_LEVELS.STREAMER}
/>
</Stack>
</Tabs.Panel>
{showPermissions && (
<Tabs.Panel value="permissions">
<Stack gap="sm">
<Group grow align="flex-start">
<Select
label="User Level"
data={Object.entries(USER_LEVELS).map(([, value]) => ({
label: USER_LEVEL_LABELS[value],
value: `${value}`,
}))}
{...form.getInputProps('user_level')}
key={form.key('user_level')}
/>
<NumberInput
label="Stream Limit (0 = unlimited)"
{...form.getInputProps('stream_limit')}
key={form.key('stream_limit')}
/>
</Group>
<MultiSelect
label="Channel Profiles"
{...form.getInputProps('channel_profiles')}
key={form.key('channel_profiles')}
onChange={onChannelProfilesChange}
data={Object.values(profiles).map((profile) => ({
label: profile.name,
value: `${profile.id}`,
}))}
/>
<Switch
label="Hide Mature Content"
description="Hide channels marked as mature content (admin users not affected)"
{...form.getInputProps('hide_adult_content', {
type: 'checkbox',
})}
key={form.key('hide_adult_content')}
/>
</Stack>
</Tabs.Panel>
)}
<Tabs.Panel value="epg">
<Stack gap="sm">
<Text size="sm" c="dimmed">
These defaults apply when no URL parameters are specified and
can be useful for XC clients that cannot pass custom query
parameters.
</Text>
<Group grow align="flex-start">
<NumberInput
label="Days forward (0 = all)"
description="How many future days of EPG data to include"
min={0}
max={365}
{...form.getInputProps('epg_days')}
key={form.key('epg_days')}
/>
<NumberInput
label="Days back (0 = none)"
description="How many past days of EPG data to include (max 30)"
min={0}
max={30}
{...form.getInputProps('epg_prev_days')}
key={form.key('epg_prev_days')}
/>
</Group>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="api">
<Stack gap="sm">
<TextInput
label="XC Password"
description={
isAdmin
? 'Clear to disable XC API'
: 'XC password can only be changed by an administrator'
}
disabled={!isAdmin}
{...form.getInputProps('xc_password')}
key={form.key('xc_password')}
rightSectionWidth={30}
rightSection={
<ActionIcon
variant="transparent"
size="sm"
color="white"
onClick={generateXCPassword}
disabled={!isAdmin}
>
<RotateCcwKey />
</ActionIcon>
}
/>
{isAdmin && (
<TagsInput
label="XC Allowed IP Ranges"
description="Restrict XC access to these CIDR ranges. Leave empty to allow all (0.0.0.0/0)."
placeholder="e.g. 192.168.1.0/24"
splitChars={[',', ' ']}
{...form.getInputProps('xc_allowed_ips')}
key={form.key('xc_allowed_ips')}
/>
)}
{canGenerateKey && (
<Stack gap="xs">
{userAPIKey && (
<TextInput
label="API Key"
disabled={true}
value={userAPIKey}
rightSection={
<ActionIcon
variant="transparent"
size="sm"
color="white"
onClick={() =>
copyToClipboard(userAPIKey, {
successTitle: 'API Key Copied!',
successMessage:
'The API Key has been copied to your clipboard.',
})
}
>
<Copy />
</ActionIcon>
}
/>
)}
<Group gap="xs" grow>
<Button
leftSection={<Key size={14} />}
size="xs"
onClick={onGenerateKey}
loading={generating}
variant="light"
fullWidth
>
{userAPIKey ? 'Regenerate API Key' : 'Generate API Key'}
</Button>
{userAPIKey && (
<Button
leftSection={<X size={14} />}
size="xs"
onClick={onRevokeKey}
loading={generating}
color={theme.colors.red[5]}
variant="light"
fullWidth
>
Revoke API Key
</Button>
)}
</Group>
</Stack>
)}
</Stack>
</Tabs.Panel>
</Tabs>
<Group justify="flex-end" mt="md">
<Button
type="submit"
variant="contained"
disabled={form.submitting}
size="small"
>
Save
</Button>
</Group>
</form>
</Modal>
);
};
export default User;