feat: make machine actions permission locked
This commit is contained in:
parent
259d150fc4
commit
58cc7b742c
@ -16,6 +16,7 @@ import cn from '~/utils/cn';
|
|||||||
|
|
||||||
interface MenuProps extends MenuTriggerProps {
|
interface MenuProps extends MenuTriggerProps {
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
|
isDisabled?: boolean;
|
||||||
children: [
|
children: [
|
||||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||||
React.ReactElement<MenuPanelProps>,
|
React.ReactElement<MenuPanelProps>,
|
||||||
@ -23,8 +24,9 @@ interface MenuProps extends MenuTriggerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: onAction is called twice for some reason?
|
// TODO: onAction is called twice for some reason?
|
||||||
|
// TODO: isDisabled per-prop
|
||||||
function Menu(props: MenuProps) {
|
function Menu(props: MenuProps) {
|
||||||
const { placement = 'bottom' } = props;
|
const { placement = 'bottom', isDisabled } = props;
|
||||||
const state = useMenuTriggerState(props);
|
const state = useMenuTriggerState(props);
|
||||||
const ref = useRef<HTMLButtonElement | null>(null);
|
const ref = useRef<HTMLButtonElement | null>(null);
|
||||||
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
|
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
|
||||||
@ -40,6 +42,7 @@ function Menu(props: MenuProps) {
|
|||||||
<div>
|
<div>
|
||||||
{cloneElement(button, {
|
{cloneElement(button, {
|
||||||
...menuTriggerProps,
|
...menuTriggerProps,
|
||||||
|
isDisabled: isDisabled,
|
||||||
ref,
|
ref,
|
||||||
})}
|
})}
|
||||||
{state.isOpen && (
|
{state.isOpen && (
|
||||||
|
|||||||
@ -18,6 +18,7 @@ interface Props {
|
|||||||
isAgent?: boolean;
|
isAgent?: boolean;
|
||||||
magic?: string;
|
magic?: string;
|
||||||
stats?: HostInfo;
|
stats?: HostInfo;
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MachineRow({
|
export default function MachineRow({
|
||||||
@ -27,6 +28,7 @@ export default function MachineRow({
|
|||||||
isAgent,
|
isAgent,
|
||||||
magic,
|
magic,
|
||||||
stats,
|
stats,
|
||||||
|
isDisabled,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const expired =
|
const expired =
|
||||||
machine.expiry === '0001-01-01 00:00:00' ||
|
machine.expiry === '0001-01-01 00:00:00' ||
|
||||||
@ -191,6 +193,7 @@ export default function MachineRow({
|
|||||||
routes={routes}
|
routes={routes}
|
||||||
users={users}
|
users={users}
|
||||||
magic={magic}
|
magic={magic}
|
||||||
|
isDisabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -16,6 +16,7 @@ interface MenuProps {
|
|||||||
users: User[];
|
users: User[];
|
||||||
magic?: string;
|
magic?: string;
|
||||||
isFullButton?: boolean;
|
isFullButton?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||||
@ -26,6 +27,7 @@ export default function MachineMenu({
|
|||||||
magic,
|
magic,
|
||||||
users,
|
users,
|
||||||
isFullButton,
|
isFullButton,
|
||||||
|
isDisabled,
|
||||||
}: MenuProps) {
|
}: MenuProps) {
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
const [modal, setModal] = useState<Modal>(null);
|
||||||
|
|
||||||
@ -96,7 +98,7 @@ export default function MachineMenu({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Menu>
|
<Menu isDisabled={isDisabled}>
|
||||||
{isFullButton ? (
|
{isFullButton ? (
|
||||||
<Menu.Button className="flex items-center gap-x-2">
|
<Menu.Button className="flex items-center gap-x-2">
|
||||||
<Cog className="h-5" />
|
<Cog className="h-5" />
|
||||||
|
|||||||
@ -8,12 +8,13 @@ import Menu from '~/components/Menu';
|
|||||||
import Select from '~/components/Select';
|
import Select from '~/components/Select';
|
||||||
import type { User } from '~/types';
|
import type { User } from '~/types';
|
||||||
|
|
||||||
export interface NewProps {
|
export interface NewMachineProps {
|
||||||
server: string;
|
server: string;
|
||||||
users: User[];
|
users: User[];
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function New(data: NewProps) {
|
export default function NewMachine(data: NewMachineProps) {
|
||||||
const [pushDialog, setPushDialog] = useState(false);
|
const [pushDialog, setPushDialog] = useState(false);
|
||||||
const [mkey, setMkey] = useState('');
|
const [mkey, setMkey] = useState('');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -25,11 +26,8 @@ export default function New(data: NewProps) {
|
|||||||
<Dialog.Title>Register Machine Key</Dialog.Title>
|
<Dialog.Title>Register Machine Key</Dialog.Title>
|
||||||
<Dialog.Text className="mb-4">
|
<Dialog.Text className="mb-4">
|
||||||
The machine key is given when you run{' '}
|
The machine key is given when you run{' '}
|
||||||
<Code isCopyable>
|
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
|
||||||
tailscale up --login-server=
|
your device.
|
||||||
{data.server}
|
|
||||||
</Code>{' '}
|
|
||||||
on your device.
|
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input type="hidden" name="_method" value="register" />
|
<input type="hidden" name="_method" value="register" />
|
||||||
<input type="hidden" name="id" value="_" />
|
<input type="hidden" name="id" value="_" />
|
||||||
@ -53,7 +51,7 @@ export default function New(data: NewProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Menu>
|
<Menu isDisabled={data.isDisabled}>
|
||||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||||
<Menu.Panel
|
<Menu.Panel
|
||||||
onAction={(key) => {
|
onAction={(key) => {
|
||||||
|
|||||||
@ -1,43 +1,65 @@
|
|||||||
import type { ActionFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs } from 'react-router';
|
||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
|
import { Capabilities } from '~/server/web/roles';
|
||||||
|
import { Machine } from '~/types';
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
import { send } from '~/utils/res';
|
import { data400, data403, data404, send } from '~/utils/res';
|
||||||
|
|
||||||
// TODO: Turn this into the same thing as dns-actions like machine-actions!!!
|
// TODO: Clean this up like dns-actions and user-actions
|
||||||
export async function menuAction({
|
export async function machineAction({
|
||||||
request,
|
request,
|
||||||
context,
|
context,
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
}: ActionFunctionArgs<LoadContext>) {
|
||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
const data = await request.formData();
|
const check = await context.sessions.check(
|
||||||
if (!data.has('_method') || !data.has('id')) {
|
request,
|
||||||
return send(
|
Capabilities.write_machines,
|
||||||
{ message: 'No method or ID provided' },
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const apiKey = session.get('api_key')!;
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
// TODO: Rename this to 'action_id' and 'node_id'
|
||||||
|
const action = formData.get('_method')?.toString();
|
||||||
|
const nodeId = formData.get('id')?.toString();
|
||||||
|
if (!action || !nodeId) {
|
||||||
|
return data400('Missing required parameters: _method and id');
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = String(data.get('id'));
|
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||||
const method = String(data.get('_method'));
|
'v1/node',
|
||||||
|
apiKey,
|
||||||
|
);
|
||||||
|
|
||||||
switch (method) {
|
const node = nodes.find((node) => node.id === nodeId);
|
||||||
|
if (!node) {
|
||||||
|
return data404(`Node with ID ${nodeId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = session.get('user')!.subject;
|
||||||
|
if (node.user.providerId?.split('/').pop() !== subject) {
|
||||||
|
if (!check) {
|
||||||
|
return data403('You do not have permission to act on this machine');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Split up into methods
|
||||||
|
switch (action) {
|
||||||
case 'delete': {
|
case 'delete': {
|
||||||
await context.client.delete(`v1/node/${id}`, session.get('api_key')!);
|
await context.client.delete(`v1/node/${nodeId}`, session.get('api_key')!);
|
||||||
return { message: 'Machine removed' };
|
return { message: 'Machine removed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'expire': {
|
case 'expire': {
|
||||||
await context.client.post(
|
await context.client.post(
|
||||||
`v1/node/${id}/expire`,
|
`v1/node/${nodeId}/expire`,
|
||||||
session.get('api_key')!,
|
session.get('api_key')!,
|
||||||
);
|
);
|
||||||
return { message: 'Machine expired' };
|
return { message: 'Machine expired' };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'rename': {
|
case 'rename': {
|
||||||
if (!data.has('name')) {
|
if (!formData.has('name')) {
|
||||||
return send(
|
return send(
|
||||||
{ message: 'No name provided' },
|
{ message: 'No name provided' },
|
||||||
{
|
{
|
||||||
@ -46,16 +68,16 @@ export async function menuAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = String(data.get('name'));
|
const name = String(formData.get('name'));
|
||||||
await context.client.post(
|
await context.client.post(
|
||||||
`v1/node/${id}/rename/${name}`,
|
`v1/node/${nodeId}/rename/${name}`,
|
||||||
session.get('api_key')!,
|
session.get('api_key')!,
|
||||||
);
|
);
|
||||||
return { message: 'Machine renamed' };
|
return { message: 'Machine renamed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'routes': {
|
case 'routes': {
|
||||||
if (!data.has('route') || !data.has('enabled')) {
|
if (!formData.has('route') || !formData.has('enabled')) {
|
||||||
return send(
|
return send(
|
||||||
{ message: 'No route or enabled provided' },
|
{ message: 'No route or enabled provided' },
|
||||||
{
|
{
|
||||||
@ -64,8 +86,8 @@ export async function menuAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = String(data.get('route'));
|
const route = String(formData.get('route'));
|
||||||
const enabled = data.get('enabled') === 'true';
|
const enabled = formData.get('enabled') === 'true';
|
||||||
const postfix = enabled ? 'enable' : 'disable';
|
const postfix = enabled ? 'enable' : 'disable';
|
||||||
|
|
||||||
await context.client.post(
|
await context.client.post(
|
||||||
@ -76,7 +98,7 @@ export async function menuAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'exit-node': {
|
case 'exit-node': {
|
||||||
if (!data.has('routes') || !data.has('enabled')) {
|
if (!formData.has('routes') || !formData.has('enabled')) {
|
||||||
return send(
|
return send(
|
||||||
{ message: 'No route or enabled provided' },
|
{ message: 'No route or enabled provided' },
|
||||||
{
|
{
|
||||||
@ -85,8 +107,8 @@ export async function menuAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = data.get('routes')?.toString().split(',') ?? [];
|
const routes = formData.get('routes')?.toString().split(',') ?? [];
|
||||||
const enabled = data.get('enabled') === 'true';
|
const enabled = formData.get('enabled') === 'true';
|
||||||
const postfix = enabled ? 'enable' : 'disable';
|
const postfix = enabled ? 'enable' : 'disable';
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@ -102,7 +124,7 @@ export async function menuAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'move': {
|
case 'move': {
|
||||||
if (!data.has('to')) {
|
if (!formData.has('to')) {
|
||||||
return send(
|
return send(
|
||||||
{ message: 'No destination provided' },
|
{ message: 'No destination provided' },
|
||||||
{
|
{
|
||||||
@ -111,22 +133,22 @@ export async function menuAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const to = String(data.get('to'));
|
const to = String(formData.get('to'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await context.client.post(
|
await context.client.post(
|
||||||
`v1/node/${id}/user`,
|
`v1/node/${nodeId}/user`,
|
||||||
session.get('api_key')!,
|
session.get('api_key')!,
|
||||||
{
|
{
|
||||||
user: to,
|
user: to,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return { message: `Moved node ${id} to ${to}` };
|
return { message: `Moved node ${nodeId} to ${to}` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return send(
|
return send(
|
||||||
{ message: `Failed to move node ${id} to ${to}` },
|
{ message: `Failed to move node ${nodeId} to ${to}` },
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
},
|
},
|
||||||
@ -136,7 +158,7 @@ export async function menuAction({
|
|||||||
|
|
||||||
case 'tags': {
|
case 'tags': {
|
||||||
const tags =
|
const tags =
|
||||||
data
|
formData
|
||||||
.get('tags')
|
.get('tags')
|
||||||
?.toString()
|
?.toString()
|
||||||
.split(',')
|
.split(',')
|
||||||
@ -144,7 +166,7 @@ export async function menuAction({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await context.client.post(
|
await context.client.post(
|
||||||
`v1/node/${id}/tags`,
|
`v1/node/${nodeId}/tags`,
|
||||||
session.get('api_key')!,
|
session.get('api_key')!,
|
||||||
{
|
{
|
||||||
tags,
|
tags,
|
||||||
@ -164,8 +186,8 @@ export async function menuAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'register': {
|
case 'register': {
|
||||||
const key = data.get('mkey')?.toString();
|
const key = formData.get('mkey')?.toString();
|
||||||
const user = data.get('user')?.toString();
|
const user = formData.get('user')?.toString();
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return send(
|
return send(
|
||||||
@ -12,9 +12,9 @@ import Tooltip from '~/components/Tooltip';
|
|||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
import type { Machine, Route, User } from '~/types';
|
import type { Machine, Route, User } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
import { menuAction } from './action';
|
|
||||||
import MenuOptions from './components/menu';
|
import MenuOptions from './components/menu';
|
||||||
import Routes from './dialogs/routes';
|
import Routes from './dialogs/routes';
|
||||||
|
import { machineAction } from './machine-actions';
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
request,
|
request,
|
||||||
@ -59,7 +59,7 @@ export async function loader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function action(request: ActionFunctionArgs) {
|
export async function action(request: ActionFunctionArgs) {
|
||||||
return menuAction(request);
|
return machineAction(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
|||||||
@ -6,17 +6,40 @@ import { ErrorPopup } from '~/components/Error';
|
|||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import Tooltip from '~/components/Tooltip';
|
import Tooltip from '~/components/Tooltip';
|
||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
|
import { Capabilities } from '~/server/web/roles';
|
||||||
import type { Machine, Route, User } from '~/types';
|
import type { Machine, Route, User } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
import { menuAction } from './action';
|
import MachineRow from './components/machine-row';
|
||||||
import MachineRow from './components/machine';
|
|
||||||
import NewMachine from './dialogs/new';
|
import NewMachine from './dialogs/new';
|
||||||
|
import { machineAction } from './machine-actions';
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
request,
|
request,
|
||||||
context,
|
context,
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
|
const user = session.get('user');
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Missing user session. Please log in again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = await context.sessions.check(
|
||||||
|
request,
|
||||||
|
Capabilities.read_machines,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!check) {
|
||||||
|
// Not authorized to view this page
|
||||||
|
throw new Error(
|
||||||
|
'You do not have permission to view this page. Please contact your administrator.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const writablePermission = await context.sessions.check(
|
||||||
|
request,
|
||||||
|
Capabilities.write_machines,
|
||||||
|
);
|
||||||
|
|
||||||
const [machines, routes, users] = await Promise.all([
|
const [machines, routes, users] = await Promise.all([
|
||||||
context.client.get<{ nodes: Machine[] }>(
|
context.client.get<{ nodes: Machine[] }>(
|
||||||
'v1/node',
|
'v1/node',
|
||||||
@ -45,11 +68,13 @@ export async function loader({
|
|||||||
publicServer: context.config.headscale.public_url,
|
publicServer: context.config.headscale.public_url,
|
||||||
agents: context.agents?.tailnetIDs(),
|
agents: context.agents?.tailnetIDs(),
|
||||||
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
|
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
|
||||||
|
writable: writablePermission,
|
||||||
|
subject: user.subject,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action(request: ActionFunctionArgs) {
|
export async function action(request: ActionFunctionArgs) {
|
||||||
return menuAction(request);
|
return machineAction(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
@ -73,6 +98,7 @@ export default function Page() {
|
|||||||
<NewMachine
|
<NewMachine
|
||||||
server={data.publicServer ?? data.server}
|
server={data.publicServer ?? data.server}
|
||||||
users={data.users}
|
users={data.users}
|
||||||
|
isDisabled={!data.writable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<table className="table-auto w-full rounded-lg">
|
<table className="table-auto w-full rounded-lg">
|
||||||
@ -123,6 +149,11 @@ export default function Page() {
|
|||||||
// This is useful for when there are no agents configured
|
// This is useful for when there are no agents configured
|
||||||
isAgent={data.agents?.includes(machine.id)}
|
isAgent={data.agents?.includes(machine.id)}
|
||||||
stats={data.stats?.[machine.nodeKey]}
|
stats={data.stats?.[machine.nodeKey]}
|
||||||
|
isDisabled={
|
||||||
|
data.writable
|
||||||
|
? false // If the user has write permissions, they can edit all machines
|
||||||
|
: machine.user.providerId?.split('/').pop() !== data.subject
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -7,3 +7,30 @@ export function send<T>(payload: T, init?: number | ResponseInit) {
|
|||||||
export function send401<T>(payload: T) {
|
export function send401<T>(payload: T) {
|
||||||
return data(payload, { status: 401 });
|
return data(payload, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function data400(message: string) {
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function data403(message: string) {
|
||||||
|
return data({
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function data404(message: string) {
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user