mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 09:06:06 +00:00
482 lines
15 KiB
JavaScript
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 & 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;
|